Skip to content

feat(room_io): mix audio from every participant into one AgentSession - #6854

Closed
Darshak-Jankat wants to merge 1 commit into
livekit:mainfrom
Darshak-Jankat:feat/mix-participant-audio-input
Closed

feat(room_io): mix audio from every participant into one AgentSession#6854
Darshak-Jankat wants to merge 1 commit into
livekit:mainfrom
Darshak-Jankat:feat/mix-participant-audio-input

Conversation

@Darshak-Jankat

Copy link
Copy Markdown

Closes #6795

Problem

AgentSession can only listen to one participant at a time: _ParticipantInputStream keeps a single _stream/_publication, so a second participant's track replaces the first instead of joining it. Use cases like an AI interview with human takeover need N participants β†’ 1 session β†’ 1 shared chat context.

Approach

Opt-in mixing. With AudioInputOptions.mix_participants=True, RoomIO subscribes to the microphone of every accepted participant and mixes them with rtc.AudioMixer (the same primitive BackgroundAudio uses) into the single audio input the session already consumes. One STT/LLM/TTS pipeline, one chat context, no API surface beyond the flag:

session.start(
    room=ctx.room,
    room_options=RoomOptions(audio_input=AudioInputOptions(mix_participants=True)),
)

Changes

  • voice/room_io/_input.py β€” _ParticipantAudioInputStream keeps a _MixedSource per participant (channel + track stream + forward task) and feeds each channel into an rtc.AudioMixer; the mixed output becomes the session's input. The only change to the shared single-participant path is a _sink(participant) hook in the base class, which still returns _data_ch β€” video and non-mixed audio behave exactly as before.
  • voice/room_io/room_io.py β€” every accepted participant is added to the mix on connect and removed on disconnect. Kind filtering is unchanged, and the agent's own avatar worker (ATTRIBUTE_PUBLISH_ON_BEHALF) is excluded so the agent can't hear itself. The linked participant is still the first one and only drives the outputs (audio, transcription, chat text).
  • voice/room_io/types.py β€” the mix_participants option.

Notes:

  • AGC runs once on the mixed output rather than per stream (one shared AudioProcessingModule interleaved across speakers would be wrong).
  • A noise-cancellation selector now builds a processor per participant, owned by that participant's stream; a directly-passed FrameProcessor instance keeps today's shared lifetime.
  • set_participant() is a no-op for the input in this mode β€” listening covers everyone; toggle it with the existing audio-input enable/disable.

Not included

Per-turn speaker attribution, also mentioned in the issue. Mixing collapses everyone into one stream, so a single STT cannot label who spoke β€” that needs per-participant recognition, which is a separate change.

Tests

tests/test_room_io.py:

  • test_mix_participants_sums_every_participant_audio β€” two participants' frames come out of the input summed into one frame.
  • test_mix_participants_tracks_room_membership β€” RoomIO mixes both humans, skips its own avatar worker, links only the first, and drops a participant on disconnect.

uv run pytest --unit passes (the pre-existing test_sent_tokenizer failure and test_room.py errors are environment-related and reproduce on main).

πŸ€– Generated with Claude Code

https://claude.ai/code/session_01XQ72uVTX79ocrm3KJ3LKYh

Adds `AudioInputOptions.mix_participants`. When enabled, RoomIO subscribes to
the microphone of every accepted participant and mixes them (rtc.AudioMixer)
into a single input stream, so one session hears the whole room with a shared
chat context. The linked participant still drives the outputs.

Closes #6795
@Darshak-Jankat
Darshak-Jankat requested a review from a team as a code owner August 14, 2026 09:31
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +459 to +460
# the sink of a mixed participant is closed when they leave, mid-forwarding
with contextlib.suppress(aio.ChanClosed):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 Leaving a participant produces a scary error traceback in the logs

The safeguard that is meant to silently ignore a departing participant's closed audio pipe is placed outside the code that reports errors (contextlib.suppress(aio.ChanClosed) at livekit-agents/livekit/agents/voice/room_io/_input.py:460), so every time a mixed participant leaves or the session shuts down while their audio is flowing, a full error traceback is written to the logs.
Impact: Normal participant departures and normal session shutdown look like crashes in production logs, making real failures harder to spot.

Mechanism: log_exceptions on the base forward task fires before the suppress

_ParticipantInputStream._forward_task is decorated with @log_exceptions(logger=logger) (livekit-agents/livekit/agents/voice/room_io/_input.py:135-136), and log_exceptions logs via logger.exception(...) and then re-raises (livekit-agents/livekit/agents/utils/log.py:16-23).

When a mixed participant is removed, remove_participant closes their channel (_input.py:319) while the per-participant forward task may be awaiting/entering await sink.send(frame) inside the base loop (_input.py:160). aio.Chan.send raises ChanClosed once close() has been called (livekit-agents/livekit/agents/utils/aio/channel.py:91-93). The base task logs the traceback first; only afterwards does the subclass's contextlib.suppress(aio.ChanClosed) swallow the exception. The same happens in aclose (_input.py:511-515), which closes each source channel before cancelling its task.

To actually get silent handling, the ChanClosed must be caught inside (or around) the send in the base _forward_task, i.e. below the log_exceptions boundary.

Prompt for agents
In livekit-agents/livekit/agents/voice/room_io/_input.py, _ParticipantAudioInputStream._forward_task wraps its body (including the call to super()._forward_task) in contextlib.suppress(aio.ChanClosed) to tolerate a mixed participant's channel being closed while frames are being forwarded. However the base _ParticipantInputStream._forward_task is decorated with @log_exceptions, which logs the exception with a full traceback via logger.exception before re-raising it, so the suppression happens too late: every participant removal (remove_participant closes the channel) and every aclose() with active mixed audio emits a bogus error traceback. Handle ChanClosed inside the base forward loop (e.g. break out of the loop when the sink is closed, or catch ChanClosed around the send) so it never crosses the log_exceptions boundary, keeping the suppress only as a secondary safety net.
Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Comment on lines 426 to 436
noise_cancellation = self._noise_cancellation
auto_close_noise_cancellation = False
if callable(noise_cancellation):
noise_cancellation = noise_cancellation(NoiseCancellationParams(participant, track))
if isinstance(noise_cancellation, rtc.FrameProcessor):
if self._mix_participants:
# each mixed participant gets its own processor, tied to its stream
auto_close_noise_cancellation = isinstance(noise_cancellation, rtc.FrameProcessor)
elif isinstance(noise_cancellation, rtc.FrameProcessor):
self._update_processor(noise_cancellation)
else:
self._update_processor(None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 A single noise-cancellation processor is fed by all speakers at once, degrading audio

When mixing is enabled and noise cancellation was supplied as a ready-made processor object, that one processor is handed to every participant's audio stream (rtc.AudioStream.from_track(..., noise_cancellation=noise_cancellation) at livekit-agents/livekit/agents/voice/room_io/_input.py:438-445), so several speakers' audio is pushed through the same stateful filter simultaneously and can come out distorted.
Impact: With multi-participant mixing, noise cancellation can degrade or mangle everyone's audio instead of cleaning it, hurting transcription quality.

Mechanism: shared FrameProcessor instance across N concurrent streams

In _create_stream (_input.py:425-445), the per-participant processor is only created when noise_cancellation is callable (a selector); in that case mixing sets auto_close_noise_cancellation=True so each stream owns its own processor. But when the user passes a rtc.FrameProcessor instance directly, self._noise_cancellation is not callable and the very same object is passed to each AudioStream.from_track β€” one per mixed participant (_on_track_available, _input.py:391).

A FrameProcessor carries per-stream state and per-stream metadata: the runtime calls _on_stream_info_updated(room_name=..., participant_identity=..., publication_sid=...) per stream (see the test double at tests/test_room_io.py:87-96), so with several streams the metadata of the last one wins, and frames from different speakers are interleaved into a single filter instance. This is exactly the reason the PR stopped sharing one AudioProcessingModule across speakers (_input.py:417-419), but the same reasoning was not applied to a directly-passed processor.

Possible handling: when mix_participants is enabled, reject/warn on a directly-passed FrameProcessor instance (asking for a selector instead), or create a per-participant processor.

Prompt for agents
In _ParticipantAudioInputStream._create_stream (livekit-agents/livekit/agents/voice/room_io/_input.py), when mix_participants is enabled and noise_cancellation was given as a concrete rtc.FrameProcessor instance (not a selector callable), the exact same processor object is passed to every participant's rtc.AudioStream. Frame processors are stateful and receive per-stream metadata via _on_stream_info_updated, so sharing one across N concurrent participant streams interleaves unrelated speakers through one filter β€” the same hazard the PR avoids for the shared AudioProcessingModule. Decide on a supported behaviour: either log a warning / raise at construction time when mix_participants is combined with a directly-passed FrameProcessor (directing users to a NoiseCancellationSelector so each participant gets its own processor), or build a per-participant processor internally.
Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

@Darshak-Jankat

Copy link
Copy Markdown
Author

Superseded by #6855, reopened from the correct account. Same commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support multiple participants in a single AgentSession with a shared chat context

2 participants