Architecture Overview for NeMo Voice Agent

View as Markdown

NeMo Labs Voice Agent is a Pipecat pipeline. The run_bot_websocket() function in examples/generic_voice_agent/server/server.py reads a YAML configuration, builds each service, and assembles a linear list of frame processors for a PipelineWorker. The remaining runtime code provides services for that list or configurations that determine which services the pipeline builds.

Components

Each stage is constructed by a small, independent builder in nemo_voice_agent/pipecat/services/nemo/builders.py. They are thin wrappers over the service constructors. They read ConfigManager properties, which keeps server.py close to a declarative description of the pipeline and limits how often you need to edit it.

BuilderReturn Value
build_audio_loggerAudioLogger or None (transport.record_audio_data)
build_vad_analyzerSileroVADAnalyzer (never None)
build_vad_processorVADProcessor, or None if given no analyzer
build_ws_transportSingleClientWebsocketServerTransport
build_sttSTT service
build_diarDiarization service or None
build_turn_takingTurn-taking service or None
build_llmLLM service for the configured llm.type
build_llm_text_processorLLMTextProcessor or None
build_ttsTTS service
build_context_and_aggregators(context, user_aggregator, assistant_aggregator, original_messages)

Because the builders are independent, a custom bot can import only the required builders and construct custom components inline. For implementation guidance, refer to Builders and Build a custom pipeline.

Data Flow

Frames flow left to right. Audio arrives from the browser on a WebSocket, and synthesized audio goes back out on the same socket.

ws.input → VAD → RTVI → STT → [Diar?] → [TurnTaking?] → [UserAudioBuffer?] → UserAggregator
→ LLM → [LLMTextProcessor?] → TTS → ws.output → AssistantAggregator

Stages in [brackets?] are omitted from the list when their builder returns None. The assembly code appends each one behind an if x is not None check, so a disabled stage leaves no placeholder behind and produces a shorter pipeline.

StageWhat It Does
ws.inputSingleClientWebsocketServerTransport input side. Deserializes protobuf frames from the client into audio frames.
VADVADProcessor wrapping a Silero analyzer. Emits VADUserStartedSpeakingFrame and VADUserStoppedSpeakingFrame. Pipecat 1.x removed VAD from the input transport, so it runs here as its own processor.
RTVIRTVIProcessor. Handles the client control protocol — client-ready handshake, client messages, and server responses.
STTStreaming NeMo ASR. Pushes transcription frames and passes the raw audio through (audio_passthrough=True), so downstream stages still see audio.
DiarStreaming speaker diarization. Pushes a DiarResultFrame whenever the dominant speaker changes.
TurnTakingNeMoTurnTakingService. Decides when the user’s turn has ended, filters backchannels, and prefixes transcripts with <speaker_N> tags when diarization is on.
UserAudioBufferBuffers the user’s raw audio and attaches it to the large language model (LLM) context for audio-input (omni) models.
UserAggregatorTurns finalized user speech into a user message on the shared LLMContext, then triggers the LLM.
LLMHugging Face, vLLM, or hosted NVIDIA endpoint, selected by llm.type. Streams text back token by token.
LLMTextProcessorRe-segments the LLM’s token stream into TTS-sized chunks using a SimpleSegmentedTextAggregator.
TTSSpeech synthesis (Kokoro by default), producing output audio frames.
ws.outputTransport output side. Serializes audio back to the client.
AssistantAggregatorAppends the assistant’s final text to the LLMContext so the next turn has history.

Optional Pipeline Stages

The following table shows when each conditional stage is present and why it can be omitted.

StageIncluded WhenNotes
VADalwaysbuild_vad_analyzer always returns a SileroVADAnalyzer, so build_vad_processor never returns None on this path. VAD is not an opt-out.
Diardiar.enabled: truetrue in the shipped default.yaml. Requires GPU.
TurnTakingturn_taking.enabled is not falseDefaults to on when the key is absent. The *_nvidia.yaml configurations set it to false and let VAD alone drive turn boundaries.
UserAudioBufferllm.is_omni_model: trueOnly the nemotron_nano_v3_omni* LLM configurations set this.
LLMTextProcessortts.use_text_aggregator is not falseDefaults to on. With it off, TTS falls back to plain sentence splitting.

Turn detection has exactly one owner. build_context_and_aggregators inspects whether a turn-taking service exists: when it does, the user aggregator is configured with ExternalUserTurnStrategies so it stays quiet and lets NeMoTurnTakingService emit the user-turn frames. When no service exists, the aggregator emits those frames directly from the VAD frames. Only one component emits UserStartedSpeakingFrame, so there is no double emission either way.

Configuration Flow

ConfigManager (nemo_voice_agent/utils/config_manager.py) loads server_configs/default.yaml, then merges the model-specific YAML named by each component’s model_config: field. This order has two important consequences:

  • The model sub-YAML overrides the top-level configuration. default.yaml sets llm.type: auto, but llm_configs/nemotron_nano_v3.yaml sets type: vllm, so the effective value is vllm. A key in default.yaml has no effect when the sub-YAML also defines it.
  • The shipped default LLM, nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-NVFP4, sets start_vllm_on_init: false — the server does not launch vLLM for you. Start it in a separate terminal first, as shown in the Quickstart.

For details, refer to Configuration and Server configuration reference.

Service Interactions

Two servers run concurrently, wired up by run_bot_with_fastapi:

ServerDefault PortEnvironment VariablePurpose
WebSocket (pipeline I/O)8765WEBSOCKET_PORTCarries audio and RTVI messages.
FastAPI7860FASTAPI_PORTPOST /connect returns the ws_url the client should dial.

POST /connect builds that URL from SERVER_PUBLIC_HOST and WEBSOCKET_SCHEME. For the complete list, refer to Environment variables.

One client at a time. The transport serves a single client. While a client is connected, a second connection is rejected. The server closes the new connection with WebSocket close code 1013 and reason Server already has a connected client. The existing client remains connected. This behavior reverses the Pipecat 1.0 behavior, in which a new connection disconnected the existing client. Do not add multi-tenant logic here because this example is single-user by design.

The pipeline outlives the connection. On disconnect, the server deliberately does not end the pipeline task because the WebSocket server lives inside the input transport. Ending the task would stop the listener while /connect continued to return a URL for an inactive port. Instead, the transport drops its socket reference and accepts the next client into the same running pipeline. The pipeline also preserves the LLMContext across reconnects, so a reconnection resumes the conversation with its history intact.

To clear history explicitly, the client sends the RTVI reset message. Its handler (create_reset_context_action) resets both aggregators back to the original system prompt and resets the resettable services. Refer to RTVI actions and Client protocol.

Initial greeting. When RTVI reports the client is ready and talk_first is set, the server queues an LLMRunFrame so the bot greets first instead of waiting for speech.

Observers

Observers watch frames without participating in the processor chain. Two observers are attached to the PipelineWorker:

  • RTVIObserver — forwards transcripts, bot-speaking events, and other pipeline events to the client as RTVI messages.
  • RTVIAudioLoggerObserver — writes session audio to disk when audio logging is on. For configuration details, refer to Audio logging.

Use these pages to run, configure, extend, or troubleshoot the architecture described here.