xr-ai-voice#

xr-ai-voice owns the voice-facing boundary for an XR agent. Pipecat implements the private media pipeline; applications use typed runtime events and model service protocols. Refer to API Reference for exact constructors, fields, and defaults.

Voice agent#

Applications register one VoiceAgent with their runtime:

from xr_ai_runtime import Topic
from xr_ai_voice import UserQuery, VadConfig, VoiceAgent, VoiceInterrupted
from xr_ai_voicegate import VoiceGateConfig

queries = Topic("my-sample.user-query", UserQuery)
interruptions = Topic("my-sample.interrupted", VoiceInterrupted)
voice = VoiceAgent(
    query_topic=queries,
    stt=stt,
    tts=tts,
    vad=VadConfig(),
    voice_gate=VoiceGateConfig(),
    interrupted_topic=interruptions,
)
runtime.register("voice", voice)

async with runtime:
    await voice.run(runtime)

VoiceAgent owns application readiness, hub transport, VAD and STT, voice gating, typed text ingress, TTS, signals, pipeline cancellation, and cleanup. Its media session remains private. Applications that need a shared public HubVoiceTransport construct and inject one explicitly.

For the startup probe contract, refer to VoiceAgent. For deployment order and migration of existing applications, refer to Consumer model readiness.

Each non-empty final STT result is queued for publication on VOICE_TRANSCRIPT_TOPIC before wake-phrase filtering. Accepted speech and untopiced typed text become UserQuery; named application and control messages are never interpreted as user text. Optional participant join, leave, and interruption topics let application agents own their state cleanup.

Transcript delivery uses one private 32-entry FIFO so a slow subscriber cannot delay STT or command gating. The queue preserves retained-item order and drops its oldest pending transcript when full. Shutdown cancels active delivery and discards pending transcripts. Runtime subscribers must enqueue long-running work internally and return promptly.

Join publication occurs after the voice gate handles its greeting. ProcessorEndpoint suppresses duplicate roster joins while a participant remains connected and emits a new join after a leave and reconnect. Join and leave publication is serialized per participant and does not block the media processor. VoiceAgent cancels and awaits all owned delivery tasks on shutdown.

Voice output and interruption#

Applications publish finite or incremental VoiceOutput values. Chunks in one stream share response_id and end with final=True. Output is serialized per participant; interrupt=True flushes queued hub audio and replaces active speech. Without aggregation, producer identity is part of the stream key so independent agents cannot merge accidentally.

Each running VoiceAgent remembers its 1,024 most recently closed stream keys. Output that reuses one of those keys is ignored, and the runtime logs one warning for that participant, producer, and response_id. Streams close by finalization, cancellation, or eviction; waiting does not reopen a retained key. Use one identifier per inbound query, such as ctx.metadata.message_id, or omit response_id when publishing a finite response in one VoiceOutput.

text_topic controls the completed-response data echo and defaults to agent.response. Set it to an empty string when the application owns its own caption channel. The echo describes intended completed text, not client playback acknowledgement.

When a participant joins, the voice transport sends 320 ms of paced silence. The first chunk causes the hub to publish the return track, and the remaining interval gives the participant time to subscribe before an immediate greeting or response. If no output follows immediately, the pre-roll drains and does not delay a later response. During speech, the sender maintains up to 120 ms of downstream reserve to absorb ordinary event-loop and IPC jitter. It sends initial chunks immediately rather than waiting to fill the reserve, and interruption flushes participant-scoped queued audio. The hub setting return_audio_max_buffer_s must be at least 0.12 for built-in voice output.

Multiple speech producers#

Applications with foreground replies, background monitors, and alerts may register one VoiceAggregationAgent. Producers publish to VOICE_CONTRIBUTION_TOPIC; only the aggregator publishes to VOICE_OUTPUT_TOPIC.

from xr_ai_voice import VOICE_CONTRIBUTION_TOPIC, VoiceAggregationAgent, VoiceOutput

aggregation = runtime.register(
    "voice-aggregation",
    VoiceAggregationAgent(llm=llm),
)

await ctx.publish(
    VOICE_CONTRIBUTION_TOPIC,
    VoiceOutput(text="The timer is done."),
)

Aggregation is participant-scoped. One finite contribution passes through after a short coalescing window; simultaneous finite updates are rewritten into one utterance. Completed text is published immediately, while a bounded open-loop spoken-duration estimate schedules later speech. Tune its word rate and playback bounds for the selected voice; the estimate is not an audio acknowledgement.

Rewrite timeout or failure falls back to ordered source text. Urgent output bypasses coalescing, cancels a rewrite, and interrupts active speech. Bounded queues prefer recent alerts over routine updates and log every drop. Dropping or interrupting a streaming contribution quarantines its response ID through its terminator or idle expiry so stale fragments cannot reopen speech.

Applications call release(participant_id) on departure and stop() before runtime shutdown. The aggregator logs accepted contributions discarded during release or shutdown.

Voice gating and early probes#

VAD and STT probe the opening audio while the user is still speaking. Probe audio includes a silent tail so offline STT can finalize a phrase. A partial global-STOP match interrupts active output immediately, but it does not commit the user’s intent: final STT remains authoritative for global-stop versus query routing. A slow probe receives a short grace period and is then cancelled. With wake phrases configured, one utterance can make up to three bounded partial STT requests plus the authoritative final request. Set stop_probe_after_s to zero to disable the additional requests and early interruption path.

A wake phrase is accepted at the beginning of a transcript or after sentence-final ., ?, or ! punctuation followed by whitespace or a closing quote. Text before the boundary and the phrase are removed. A phrase after a comma, semicolon, or inside ordinary prose does not activate the gate. Partial STOP classification checks both raw text and the tail after a configured wake phrase, so stop and hey agent stop interrupt equally early.

Global STOP uses a closed imperative grammar for direct requests such as stop, stop it, stop talking, be quiet, and shut up, with a bounded set of conversational prefixes and punctuation. Up to two prefixes may be drawn from please, hey, okay, ok, uh, um, wait, no, just, alright, sorry, whoa, hang on, I said, or can, could, would, or will followed by you. Negations (don't stop), questions (should I stop?), reported speech (you said stop), unconfigured arbitrary prefixes, and scoped or multi-action commands (stop monitoring xyz) are not global stops. They follow the ordinary gate rules.

A partial STOP match emits only an interruption, not a chime or stop acknowledgement. Wake recognition may independently emit the optional chime, but chime configuration, initialization, or playback does not control probing. If the final transcript remains a global stop, normal stop handling emits the acknowledgement. If STT revises partial hey agent stop to final hey agent stop monitoring xyz, the final transcript instead dispatches stop monitoring xyz to the agent; the latency-saving interruption is not undone.

Relay telemetry#

Voice output fragments use a low-cardinality runtime topic. VoiceAgent emits one semantic voice.response scope per finite response or completed stream. The media pipeline emits one voice.stt scope per transcription and one voice.tts scope per sentence synthesis. STT records audio size, duration, and sample rate plus a transcript result mark; TTS records its sentence. Raw audio is never written to Relay events, and these timings end at provider handoff, not client playback.