Skip to content

fix(avatar): restore previous audio output on AvatarSession.aclose() - #7477

Open
dajiaohuang wants to merge 2 commits into
livekit:mainfrom
dajiaohuang:fix/7276-avatar-aclose-restore-audio
Open

dajiaohuang wants to merge 2 commits into
livekit:mainfrom
dajiaohuang:fix/7276-avatar-aclose-restore-audio

Conversation

@dajiaohuang

Copy link
Copy Markdown
Contributor

When an AvatarSession installs its DataStreamAudioOutput via replace_audio_tail(), it never restored the previous audio tail when aclose() ran. If avatar.start() failed (or the session was closed early), the agent remained muted because subsequent audio went to the orphaned avatar DataStreamAudioOutput.

  • Add _replace_audio_tail() helper that saves the prior audio tail
  • Restore the saved tail in aclose() before other cleanup
  • Update all avatar plugins (inference, anam, keyframe, bey, trugen, synthesia, lemonslice, runway, tavus, protoface, avatario, avatartalk, bithuman, did, liveavatar, simli, spatius) to use the helper

Fixes #7276

When an AvatarSession installs its DataStreamAudioOutput via
replace_audio_tail(), it never restored the previous audio tail when
aclose() ran. If avatar.start() failed (or the session was closed early),
the agent remained muted because subsequent audio went to the orphaned
avatar DataStreamAudioOutput.

- Add _replace_audio_tail() helper that saves the prior audio tail
- Restore the saved tail in aclose() before other cleanup
- Update all avatar plugins (inference, anam, keyframe, bey, trugen,
  synthesia, lemonslice, runway, tavus, protoface, avatario, avatartalk,
  bithuman, did, liveavatar, simli, spatius) to use the helper

Fixes livekit#7276
@dajiaohuang
dajiaohuang requested a review from a team as a code owner September 25, 2026 15:39
@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 3 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread livekit-agents/livekit/agents/voice/avatar/_types.py
Comment on lines +124 to +126
if self._agent_session and self._previous_audio_output is not None:
try:
self._agent_session.output.replace_audio_tail(self._previous_audio_output)

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.

🔴 Audio remains routed to closed avatar

When an avatar starts before the agent has an audio output, _replace_audio_tail saves None. Later, aclose() skips restoration, leaving speech routed to the closed avatar.

Learn more

A common startup order runs AvatarSession.start() before AgentSession.start(). In that order AgentOutput.audio can be None when the avatar sink is installed, so _previous_audio_output remains None. The agent's startup then disables its RoomIO audio output because an avatar sink is already configured AgentSession.start. The new close guard skips restoration for None; subsequent speech still targets the orphaned avatar sink.

Example: A fresh agent has no output.audio. Start a LemonSlice avatar, start the agent, then close the avatar while the agent continues running. The agent's output remains the avatar data stream, not a working room audio output.

Recommended fix: Track whether an avatar sink was installed separately from whether a previous sink existed. Define a valid fallback for the no-prior-sink case, including the agent's RoomIO output lifecycle when the avatar preceded AgentSession.start().

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +124 to +126
if self._agent_session and self._previous_audio_output is not None:
try:
self._agent_session.output.replace_audio_tail(self._previous_audio_output)

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.

🔴 Older avatar close overrides newer audio

If another avatar replaces the tail, the earlier avatar's aclose() restores its stale output over the new avatar. The newer avatar stops receiving speech; closing it can reinstall the earlier avatar's orphaned sink.

Learn more

Each avatar caches its predecessor independently, but close never checks whether that avatar still owns the current tail. Two avatars can be started on the same agent without a guard in AvatarSession.start. Closing them out of start order swaps in an older sink and prevents the still-running avatar from receiving speech.

Example: Agent output is room; avatar A starts and saves room; avatar B starts and saves A's output. A closes and installs room over B. B closes and reinstalls A's now-orphaned output.

Recommended fix: Track the sink installed by each avatar and restore only when it remains the current tail; handle overlapping avatars with explicit ownership or a stack. Do not overwrite a tail installed after this avatar.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I checked the three cases against this head: it saves output.audio even though replace_audio_tail() swaps the sink under the proxy, treats None as if no route needs restoring, and restores without checking whether a newer route replaced this avatar. Those are real gaps in this patch. PR #7282 is already open for #7276 and covers restoring the tail under wrappers, clearing a route installed over nothing, and preserving a newer route, with focused regression tests. I’m leaving this branch unchanged to avoid a competing partial fix.

- Use getattr for defensive attribute access (works with test mocks)
- Traverse next_in_chain to find _AudioSinkProxy and save its downstream sink
- Import _AudioSinkProxy from io module
@dajiaohuang

Copy link
Copy Markdown
Contributor Author

Fixed the unit test failures. The issue was in _replace_audio_tail in livekit-agents/livekit/agents/voice/avatar/_types.py:

  1. AttributeError fix: The code directly accessed self._agent_session.output.audio, but test mocks (_FakeOutput) don't have this attribute. Switched to getattr() for defensive access.

  2. Correct chain traversal: The original code saved the head of the audio chain (output.audio), but replace_audio_tail replaces the tail sink downstream of _AudioSinkProxy. We need to traverse next_in_chain to find the proxy and save its next_in_chain (the actual tail sink) for proper restoration in aclose().

Changes:

  • Added import for _AudioSinkProxy from ..io
  • Updated _replace_audio_tail() to traverse the audio chain to find the proxy and save the correct previous tail sink
  • Uses getattr() for defensive attribute access compatible with test mocks

All 30 inference avatar tests and 166 synthesia plugin tests pass locally.

This branch has not been deployed

No deployments
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.

AvatarSession.aclose() doesn't unbind the audio output start() installed, so a failed avatar start silently mutes the agent

2 participants