Overview
Voxray’s pipeline is a linear chain of processors. Each processor receives a frame, does work (filtering, transforming, calling an external API), and pushes the result downstream. Processors are registered by name in a global registry and instantiated at startup fromconfig.json — no recompilation required to swap or reorder processors.
The registry lives in pkg/pipeline/registry.go:
cmd/voxray/main.go imports all processor packages via blank imports. Each package’s init() function calls pipeline.RegisterProcessor(name, ctor), which adds the constructor to the in-memory registry. When a new transport connection arrives, ProcessorsFromConfig iterates config.Plugins, looks up each name in the registry, and calls the constructor with the matching plugin_options blob — or nil if no options were provided.
If a plugin name in config.Plugins is not found in the registry, ProcessorsFromConfig returns an error and the server refuses to start. This makes config mistakes loud and explicit.
Built-in Plugins
Wiring Plugins in Config
Plugins are declared in two parallel arrays inconfig.json:
plugins— ordered list of processor names. Voxray builds the pipeline in this order, left to right.plugin_options— a map from processor name to an arbitrary JSON object passed to that processor’s constructor.
plugins matters: frames flow through processors in declaration order. In the example above, audio arrives at stt_mute_filter first (where it may be dropped if the bot is speaking), then passes to interruption_controller (which checks whether a barge-in should be declared), and finally reaches rtvi (which serialises bot output into RTVI protocol messages for the frontend).
If a processor name appears in
plugins but not in plugin_options, the constructor receives nil for opts and must apply its own defaults. All built-in processors handle nil opts gracefully.Common plugin option reference
frame_filter
allowed_types are dropped.
wake_check_filter
TranscriptionFrame text.
stt_mute_filter
audio_filter
interruption_controller
RTVI Protocol
RTVI (Real-Time Voice Interface) is the client–server messaging layer used by Pipecat-based frontend clients. Voxray implements both the processor and the wire serializer.Enabling RTVI
Two steps are required:- The WebSocket client must connect to
/ws?rtvi=1. The?rtvi=1query parameter tells the server to select the RTVI serializer instead of the default JSON or binary serializer. "rtvi"must appear inplugins. The RTVIProcessor handles the handshake and message routing.
Message types
Flow:
- Client connects to
ws://host:port/ws?rtvi=1. - Voxray pushes
StartFrameinto the pipeline; RTVIProcessor responds withbot-ready. - Client sends
client-ready; RTVIProcessor records the client version. - Client sends
send-text; RTVIProcessor converts it to aTranscriptionFrameand pushes it downstream (to LLM → TTS if in a voice pipeline, or to any downstream processor in a plugin pipeline). LLMTextFrameand other output frames are serialized asbot-outputand sent to the client.
External Chain (Python Sidecar)
external_chain bridges the Go pipeline to a Python LangChain, LangGraph, or Strands service over HTTP. This is the recommended pattern when your agent logic requires Python-only libraries (e.g. custom LangChain tools, complex graph traversal, retrieval-augmented generation with Python vector stores).
How it works
When anLLMContextFrame arrives, external_chain extracts the last user message and POSTs it to the configured URL. The response is streamed back as LLMTextFrame instances, followed by LLMFullResponseStartFrame / LLMFullResponseEndFrame markers. Downstream processors (e.g. TTS) consume these exactly as they would from a native LLM provider.
Config
Streaming response format (when
stream: true): SSE lines data: {"text":"..."} or {"content":"..."}. Each chunk is emitted as an LLMTextFrame in real time, enabling TTS to begin speaking before the full response is received.
Python sidecar contract:
Writing a Custom Processor
Custom processors follow a five-step pattern: define a struct, implement the interface, register withinit(), import the package, and add to config.
1
Create a Go package
Create a new directory under
pkg/processors/:2
Define the struct
Embed
*processors.BaseProcessor, which provides the downstream push channel, name, and default no-op implementations:3
Implement ProcessFrame
ProcessFrame is called for every frame that reaches this processor. Call p.PushDownstream to forward frames to the next processor in the chain. Frames you do not forward are effectively dropped.Always call
p.PushDownstream(ctx, frame) for frames you do not want to drop, including frame types your processor does not handle. Failing to forward a frame type like StartFrame or CancelFrame will break pipeline lifecycle management.4
Register with init()
The
init() function runs automatically when the package is imported. It registers your constructor under the name that config will reference:5
Blank-import the package in main.go
Go’s This is the same pattern used by all built-in processors (e.g.
init() only runs if the package is imported. Add a blank import to cmd/voxray/main.go:pkg/processors/voice/register.go registers interruption_controller this way).6
Add to config
Add your processor name to Restart the server. Voxray will log a startup error and refuse to run if your processor name is in
plugins and provide any options under plugin_options:plugins but not in the registry — a fast feedback loop for typos or missing imports.Processor Interface Reference
BaseProcessor implements all methods with safe defaults. Override only ProcessFrame (and optionally Setup/Cleanup for resource lifecycle) in custom processors.
Frame direction
FrameDirectionDownstream. The interruption_controller uses FrameDirectionUpstream to propagate cancellation signals back toward the TTS processor.
Pipeline Execution Model
Frames flow synchronously through the processor chain within a single goroutine perPush call. Processors must not block indefinitely — use ctx for cancellation. If a processor needs to spawn background work (e.g. an async HTTP call), it should push a result frame from that goroutine via a channel and a separate Pipeline.Push call, not block ProcessFrame.
The runner feeds frames into the pipeline via a buffered queue (default capacity: 256 frames). If the pipeline falls behind, the queue fills and the transport reader blocks — providing back-pressure to the client. This is intentional and prevents unbounded memory growth under load.
Sink appended by the pipeline builder, which writes frames to Transport.Output. You do not need to wire the sink manually.