Pre-STT addressee gating for LiveKit voice agents

Your LiveKit agent sends every turn to STT, addressed or not.

Selective Auditory Attention (SAA) gates audio before STT so only device-directed turns reach your pipeline.

Five pipeline layers; only layer 5 answers who's being addressed.

A user turns to talk to someone nearby: VAD and STT keep running anyway. SAA gates before STT, deciding addressee every 250 ms.

Pipeline layer What it answers Timing Answers "is this turn addressed to the device?"
L1: Noise cleanup / AEC Is the audio clean? Pre-STT No
L2: VAD / wake-word Is someone speaking? Did the trigger phrase fire? Pre-STT No
L3: Speaker diarization Which speaker said it, after the fact? Post-STT No
L4: Turn detection / endpointing Has the turn ended? Pre-STT / in-flight No
L5: SAA addressee gating Is this turn addressed to the device, right now? Pre-STT, every 250 ms Yes

A hidden SAA participant subscribes to your tracks and publishes decisions.

Calling start_attention_session adds that participant to your room. It can subscribe to tracks and publish data, never media, and emits a decision every 250 ms.

User participant audio track video track LiveKit room SAA hidden participant identity: saa-agent subscribe only no media publish subscribe to audio + video 'saa' data channel PredictionEvent / 250 ms Your agent AttentionEngine callbacks
User participant audio track video track LiveKit room subscribe to audio + video SAA hidden participant identity: saa-agent subscribe only no media publish decision every 250 ms 'saa' data channel PredictionEvent / 250 ms deliver to callbacks Your agent AttentionEngine callbacks
The hidden SAA participant subscribes to audio and video, never publishes media, and emits a decision every 250 ms for AttentionEngine to deliver to your callbacks.

aligned_class schema

Three classes; gate on class 2 only

aligned_class is 0 (silence), 1 (human to human), or 2 (device-directed). Gate: voice.input.set_audio_enabled(p.aligned_class == 2).

Use aligned_class, not raw_class: it smooths transient class-2 flips in the frames right after AI playback ends.

minimum-grant JWT

Subscribe and publish-data only; never media publish

attention_agent_token issues a JWT with room_join, hidden, can_subscribe, can_publish_data, and agent grants. can_publish is always False.

One callback expression to gate an AgentSession you already have running.

Add the gate without restructuring your entrypoint. Construct an AttentionEngine and wire three callbacks, shown below.

from saa_livekit import start_attention_session, AttentionEngine

async def entrypoint(ctx: JobContext):
    await ctx.connect()

    # Summon the hosted SAA participant into this room
    saa = await start_attention_session(
        room=ctx.room,
        participant_identity=ctx.room.local_participant.identity,
    )

    # Construct the engine that routes PredictionEvents to your callbacks
    engine = AttentionEngine(
        room=ctx.room,
        agent_identity=ctx.room.local_participant.identity,
    )

    # Gate: the entire addressee decision is one expression
    @engine.on_prediction
    async def _(p):
        voice.input.set_audio_enabled(p.aligned_class == 2)

    # Interrupt: stop TTS when a confident class-2 streak fires during playback
    @engine.on_interrupt
    async def _(ev):
        voice.interrupt()

    # Responding state: arm the interrupt detector during AI playback
    @session.on("agent_state_changed")
    def _(old_state, new_state):
        if new_state == "speaking":
            engine.responding_start()
        elif old_state == "speaking":
            engine.responding_stop()

    ctx.add_shutdown_callback(saa.stop)
    ctx.add_shutdown_callback(engine.stop)

build_attention_entrypoint: a factory helper for teams starting from scratch.

build_attention_entrypoint returns a coroutine you can pass to your agent server. Give it your async on_turn handler; SAA handles the rest.

from livekit.agents import JobContext, AgentServer
from saa_livekit import build_attention_entrypoint, TurnReadyEvent

async def on_turn(ev: TurnReadyEvent, ctx: JobContext):
    # ev.audio_pcm16: int16 mono PCM at 16 kHz, ready for any LLM audio API
    if ev.context == "interjection_follow_up":
        # Turn was captured after a successful interjection;
        # route to a different LLM prompt (e.g. "briefly offer to help")
      ...
        return
    # Forward ev.audio_pcm16 to your STT pipeline or realtime model

# Plug the returned coroutine into your agent server
entrypoint = build_attention_entrypoint(
    on_turn=on_turn,
    # on_interrupt=handle_interrupt,   # optional
    # on_interjection=handle_interject, # optional
)

server = AgentServer()
server.rtc_session(entrypoint)
# or: WorkerOptions(entrypoint_fnc=entrypoint)

TurnReadyEvent

int16 mono PCM at 16 kHz, ready to forward

audio_pcm16 is int16 mono PCM at 16 kHz, ready for any LLM or STT API. context is None for a fresh command, or 'interjection_follow_up' after one.

environment variables

No runtime configuration required

build_attention_entrypoint reads your SAA and LiveKit credentials from environment variables automatically; no constructor arguments needed. Full list in Setup and requirements below.

Disable LiveKit VAD; let SAA own turn boundaries for realtime models.

Set turn_detection=None so the model hears only SAA-injected turns. attention_config can disable frame capture for audio-only pipelines.

from livekit.plugins import openai
from saa_livekit import build_attention_entrypoint, inject_realtime_turn, TurnReadyEvent

async def on_turn(ev: TurnReadyEvent, ctx):
    # inject_realtime_turn chunks the PCM at 100 ms intervals,
    # calls rt.push_audio for each chunk, rt.commit_audio,
    # then calls session.generate_reply()
    rt = ctx.proc.userdata["rt_session"]
    await inject_realtime_turn(rt, ev)

# Disable server VAD: SAA owns turn boundaries
model = openai.realtime.RealtimeModel(
    turn_detection=None,
)
session = model.session()
session.input.set_audio_enabled(False)  # model hears only SAA-injected audio

# Disable per-turn frame capture for pure audio pipelines
entrypoint = build_attention_entrypoint(
    on_turn=on_turn,
    attention_config={"frames_per_turn": 0},
)

Two typed events: barge-in during playback and proactive interjection.

Call engine.responding_start() when playback begins and responding_stop() when it ends. This arms the interrupt branch.

01

InterruptEvent: barge-in while the agent is speaking

A confident class-2 streak during playback fires on_interrupt. Call session.interrupt() to stop TTS; SAA has already pre-rolled the audio, so the next turn carries the question.

02

InterjectionEvent: humans went quiet while still in frame

InterjectionEvent fires when people go quiet mid-conversation while still in frame (reason='stuck_after_question'). Route audio_pcm16 to your LLM with a brief offer-to-help prompt.

One package, four environment variables, Python 3.10 or later.

Install saa-livekit-client and set the four environment variables below. SAA supports audio-only and performs best with video. Methodology: arXiv:2604.08412.

pip install saa-livekit-client
01

Required environment variables

SAA_API_KEY: your attention labs API key, self-serve from the dashboard, no approval needed.

LIVEKIT_URL: your LiveKit server URL, publicly reachable from the SAA cloud.

LIVEKIT_API_KEY and LIVEKIT_API_SECRET: your LiveKit project credentials, used by the broker to issue the hidden participant's minimum-grant JWT.

02

Networking and runtime

Python 3.10 or later. saa-livekit-client runs inside your worker process, no inbound changes. Outbound only, to broker.attentionlabs.ai. See the networking FAQ below.

03

Threshold tuning

set_threshold(v) takes a float from 0.0 to 1.0. Raise it in noisy environments to cut false triggers; lower it for quiet or distant speakers. Default is tuned for one participant in view of the camera.

04

Multi-user rooms

One call to start_attention_session per target participant. saa-livekit-client is the Cloud SDK's LiveKit adapter, built on the base transport packages @attenlabs/saa-js (npm) and attenlabs-saa (PyPI).

What LiveKit agent developers ask before integrating.

What does SAA actually add to a LiveKit room?

Calling start_attention_session summons a hidden participant (identity saa-agent by default) into your LiveKit room via broker.attentionlabs.ai. It subscribes to the user's audio and video tracks and publishes decisions on the saa data channel every 250 ms. Your AttentionEngine reads those decisions and fires typed callbacks. The participant holds minimum-necessary grants: subscribe and publish-data only, never media.

How does the addressee gate actually suppress non-addressed audio?

Every 250 ms, AttentionEngine fires an on_prediction callback with a PredictionEvent. Read aligned_class: 0 is silence, 1 is human-to-human speech, 2 means the user is addressing the device. Gating is one expression: voice.input.set_audio_enabled(p.aligned_class == 2). Use aligned_class rather than raw_class; it applies a 5-tick AI-aware correction that suppresses transient class-2 flips in the frames immediately after AI playback ends.

Can SAA replace LiveKit server VAD for speech-to-speech agents?

Yes. Set turn_detection=None on the RealtimeModel and session.input.set_audio_enabled(False) so the model hears only SAA-injected audio. When a complete addressed turn ends, on_turn_ready fires with a TurnReadyEvent carrying audio_pcm16: int16 mono PCM at 16 kHz. The inject_realtime_turn helper chunks the PCM at 100 ms steps, calls push_audio for each chunk, commit_audio, then generate_reply. SAA owns turn boundaries instead of the model's own VAD.

What happens when a user interrupts while the agent is speaking?

Call engine.responding_start() when your agent begins audio playback and engine.responding_stop() when it finishes. If SAA observes a confident class-2 speech streak during playback, on_interrupt fires with an InterruptEvent. The hosted agent has already pre-rolled ring-buffer audio into the chunk accumulator; the next on_turn_ready carries the user's barge-in question. Call session.interrupt() in the on_interrupt callback to stop TTS.

What networking does the integration require?

Your LiveKit URL must be publicly reachable from broker.attentionlabs.ai. The saa-livekit-client library runs inside your existing agent worker process with no inbound networking changes on your side. It communicates outbound only: one POST to start the session and one DELETE to stop it, both to broker.attentionlabs.ai using your SAA_API_KEY.

What is the fail-closed behavior and why does it matter?

SAA errs toward suppression rather than forwarding. Audio is not sent onward unless the model is confident the user is addressing the device (class 2). A turn that shifts mid-sentence from device-directed speech to a side conversation drops to class 1, the gate closes immediately, and nothing reaches STT or the LLM. A low-confidence utterance produces a missed command rather than a ghost response from an AI that was not being spoken to.

Get your API key and start gating your LiveKit agent today.

Sign up for a free SAA_API_KEY and install saa-livekit-client. Need help with a nonstandard pipeline? Talk to us.

Related: how addressee detection works, Pipecat addressee detection integration, OpenAI Realtime addressee detection integration, voice infrastructure pipeline, engagement-control layer.