Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
27c841a
feat: add MVP of propagating room downwards from room -> track -> aud…
1egoman May 18, 2026
15fe1b4
feat: call _on_stream_info_updated with parent room reference on audi…
1egoman May 19, 2026
56e4b95
feat: call _on_credentials_updated with token / server url extracted …
1egoman May 20, 2026
4fdef63
fix: remove debugging logs
1egoman May 20, 2026
5a55617
fix: address lint errors
1egoman May 20, 2026
83dada3
feat: only call frame processor handlers if room is set
1egoman May 20, 2026
2b96668
fix: properly intercept room refresh token events
1egoman May 20, 2026
46f11dd
feat: add from __future__ import annotations to remove string types
1egoman May 26, 2026
4fc15ea
fix: address incorrect docs
1egoman May 26, 2026
8bc8953
refactor: centralize frame processor state logic into Track, not Audi…
1egoman May 26, 2026
dedf686
feat: add auto cleanup of FrameProcessor as opt-out
1egoman May 26, 2026
1699ad4
fix: disable no-op credentials push
1egoman May 26, 2026
277b634
fix: move processor close from __del__ to aclose
1egoman May 26, 2026
6bcdd5b
fix: proxy throgh noise_cancellation_leave_open into AudioStream.from…
1egoman May 26, 2026
a8d3879
fix: include missed noise_cancellation_leave_open in from_track
1egoman May 26, 2026
4c7a95e
fix: address type checker warning
1egoman May 26, 2026
6a2c5f2
feat: add new _on_stream_info_cleared / _on_credentials_cleared Frame…
1egoman May 27, 2026
d93f881
fix: apply devin suggestion
1egoman May 27, 2026
2c9d8de
feat: add new frame processor tests
1egoman May 27, 2026
ea1cde0
fix: address type errors in tests
1egoman May 27, 2026
8e3a461
fix: rename noise_cancellation_leave_open -> auto_close_noise_cancell…
1egoman Jun 1, 2026
ea1de09
fix: address incorrect default value for auto_close_noise_cancellation
1egoman Jun 8, 2026
cb47aec
fix: ensure that audio stream metadata is updated properly on room fu…
1egoman Jun 8, 2026
b9a9c15
fix: ensure that audio stream room reference is cleared when track is…
1egoman Jun 8, 2026
92e334e
fix: use proper ffi objects in test instead of types.SimpleNamespace …
1egoman Jun 8, 2026
e8b12a4
fix: ensure that track._set_room(None) is idempotent, and only calls …
1egoman Jun 18, 2026
4fe5018
fix: reset unpublished._track = None on track unpublish
1egoman Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions livekit-rtc/livekit/rtc/audio_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def __init__(
num_channels: int = 1,
frame_size_ms: int | None = None,
noise_cancellation: Optional[NoiseCancellationOptions | FrameProcessor[AudioFrame]] = None,
auto_close_noise_cancellation: bool = True,
Comment thread
1egoman marked this conversation as resolved.
**kwargs: Any,
) -> None:
"""Initialize an `AudioStream` instance.
Expand All @@ -81,6 +82,9 @@ def __init__(
noise_cancellation (Optional[NoiseCancellationOptions | FrameProcessor[AudioFrame]], optional):
If noise cancellation is used, pass a `NoiseCancellationOptions` or `FrameProcessor[AudioFrame]` instance
created by the noise cancellation module.
auto_close_noise_cancellation (bool):
When the audio stream closes, should the FrameProcessor's close method be run? If
False, then the frame processor can be reused with another AudioStream.

Example:
```python
Expand Down Expand Up @@ -113,11 +117,13 @@ def __init__(
self._audio_filter_module: str | None = None
self._audio_filter_options: dict[str, Any] | None = None
self._processor: FrameProcessor[AudioFrame] | None = None
self._processor_auto_close = True
if isinstance(noise_cancellation, NoiseCancellationOptions):
self._audio_filter_module = noise_cancellation.module_id
self._audio_filter_options = noise_cancellation.options
elif isinstance(noise_cancellation, FrameProcessor):
self._processor = noise_cancellation
self._processor_auto_close = auto_close_noise_cancellation

self._task = self._loop.create_task(self._run())
self._task.add_done_callback(task_done_logger)
Expand All @@ -132,6 +138,9 @@ def __init__(
self._ffi_handle = FfiHandle(stream.handle.id)
self._info = stream.info

if self._track is not None:
self._track._register_audio_stream(self)
Comment thread
1egoman marked this conversation as resolved.

@classmethod
def from_participant(
cls,
Expand All @@ -144,6 +153,7 @@ def from_participant(
num_channels: int = 1,
frame_size_ms: int | None = None,
noise_cancellation: Optional[NoiseCancellationOptions | FrameProcessor[AudioFrame]] = None,
auto_close_noise_cancellation: bool = True,
) -> AudioStream:
"""Create an `AudioStream` from a participant's audio track.

Expand Down Expand Up @@ -179,8 +189,9 @@ def from_participant(
track=None, # type: ignore
sample_rate=sample_rate,
num_channels=num_channels,
noise_cancellation=noise_cancellation,
frame_size_ms=frame_size_ms,
noise_cancellation=noise_cancellation,
auto_close_noise_cancellation=auto_close_noise_cancellation,
)

@classmethod
Expand All @@ -194,6 +205,7 @@ def from_track(
num_channels: int = 1,
frame_size_ms: int | None = None,
noise_cancellation: Optional[NoiseCancellationOptions | FrameProcessor[AudioFrame]] = None,
auto_close_noise_cancellation: bool = False,
) -> AudioStream:
"""Create an `AudioStream` from an existing audio track.

Expand All @@ -203,9 +215,12 @@ def from_track(
capacity (int, optional): The capacity of the internal frame queue. Defaults to 0 (unbounded).
sample_rate (int, optional): The sample rate for the audio stream in Hz. Defaults to 48000.
num_channels (int, optional): The number of audio channels. Defaults to 1.
noise_cancellation (Optional[NoiseCancellationOptions], optional):
If noise cancellation is used, pass a `NoiseCancellationOptions` instance
noise_cancellation (Optional[NoiseCancellationOptions | FrameProcessor[AudioFrame]], optional):
If noise cancellation is used, pass a `NoiseCancellationOptions` or `FrameProcessor[AudioFrame]` instance
created by the noise cancellation module.
auto_close_noise_cancellation (bool):
When the audio stream closes, leaves the FrameProcessor in an unclosed state so it
can be used with another AudioStream.

Returns:
AudioStream: An instance of `AudioStream` that can be used to receive audio frames.
Expand All @@ -225,8 +240,9 @@ def from_track(
capacity=capacity,
sample_rate=sample_rate,
num_channels=num_channels,
noise_cancellation=noise_cancellation,
frame_size_ms=frame_size_ms,
noise_cancellation=noise_cancellation,
auto_close_noise_cancellation=auto_close_noise_cancellation,
)

def __del__(self) -> None:
Expand Down Expand Up @@ -303,8 +319,12 @@ async def aclose(self) -> None:
This method cleans up resources associated with the audio stream and waits for
any pending operations to complete.
"""
if self._track is not None:
self._track._unregister_audio_stream(self)
self._ffi_handle.dispose()
await self._task
if self._processor is not None and self._processor_auto_close:
self._processor._close()

def _is_event(self, e: proto_ffi.FfiEvent) -> bool:
return e.audio_stream_event.stream_handle == self._ffi_handle.handle
Expand Down
4 changes: 4 additions & 0 deletions livekit-rtc/livekit/rtc/frame_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,12 @@ def _on_stream_info_updated(
publication_sid: str,
) -> None: ...

def _on_stream_info_cleared(self) -> None: ...

def _on_credentials_updated(self, *, token: str, url: str) -> None: ...

def _on_credentials_cleared(self) -> None: ...

@abstractmethod
def _process(self, frame: T) -> T: ...

Expand Down
9 changes: 9 additions & 0 deletions livekit-rtc/livekit/rtc/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,15 @@ async def unpublish_track(self, track_sid: str) -> None:
# so when it processed local_track_unpublished first.
self._track_publications.pop(track_sid, None)
if publication is not None:
# Clear the processor's room context here too: this path races
# the local_track_unpublished room event, and whichever loses
# the race finds the publication already gone and skips its own
# _set_room(None). Calling it from both paths guarantees the
# processor is cleared (and the token_refreshed listener
# detached) exactly once it matters; _set_room(None) is
# idempotent, so a double-clear when this path wins is safe.
if publication._track is not None:
publication._track._set_room(None)
publication._track = None
queue.task_done()
finally:
Expand Down
23 changes: 23 additions & 0 deletions livekit-rtc/livekit/rtc/room.py
Comment thread
1egoman marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,8 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None:
sid = event.local_track_published.track_sid
lpublication = self.local_participant.track_publications[sid]
ltrack = lpublication.track
if ltrack is not None:
ltrack._set_room(self)
self.emit("local_track_published", lpublication, ltrack)
elif which == "local_track_unpublished":
# During teardown the publication may already have been removed
Expand All @@ -757,6 +759,14 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None:
unpublished = self.local_participant._track_publications.get(sid)
if unpublished is not None:
del self.local_participant._track_publications[sid]
if unpublished.track is not None:
unpublished.track._set_room(None)
Comment thread
1egoman marked this conversation as resolved.
# Mirror track_unsubscribed: drop the publication's track
# reference. This also makes unpublish_track's own
# _set_room(None) a no-op when it loses the race (its
# `publication._track is not None` guard short-circuits),
# avoiding a redundant clear.
unpublished._track = None
self.emit("local_track_unpublished", unpublished)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines +762 to 770

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.

🚩 local_track_unpublished event now emits with publication.track = None — behavioral change

Before this PR, the local_track_unpublished room-event handler emitted the event with publication.track still set (the track was only nulled later by unpublish_track). Now at room.py:769, unpublished._track = None is set BEFORE the emit at room.py:770. This is intentional (the comment explains it mirrors track_unsubscribed and prevents a redundant _set_room(None) in the race), and existing callers in this repo only access publication.sid. However, any downstream consumer (e.g. agents SDK) that accesses publication.track inside a local_track_unpublished callback will now always see None, where previously it was sometimes set. Unlike track_unsubscribed (which passes the track as a separate argument), this event provides no alternative way to access the track.

Open in Devin Review

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is sort of the same thing that was surfaced here just flipped around?

I don't really have the depth of knowledge on this code to know for sure what to do here. Here's what a LLM had to say about this whole situation:

Is it actually a breaking change? Not meaningfully.

  Three reasons:

  1. It was never reliable before. Because of the documented unpublish race, unpublish_track could null publication._track before the room event handler
  ran. So pre-PR, a callback reading publication.track already saw None sometimes (race-dependent). Any correct consumer already had to handle None. We've
  gone from "racy/sometimes-set" to "always None" — not from "guaranteed-set" to "None."
  2. No consumer reads it. I grepped both repos for local_track_unpublished handlers:
    - python-sdks: tests/rtc/test_e2e.py and examples/basic_room.py — both read only publication.sid.
    - agents SDK: doesn't subscribe to local_track_unpublished at all.        

  So there's zero actual breakage today.                                      
  3. The event never offered guaranteed track access anyway. Unlike track_unsubscribed (which passes the track as a separate emit arg),
  local_track_unpublished only emits the publication. So publication.track was the only route, and it was always racy.

  Net: it's an observable behavior change to a public event's payload, but not a break that affects any real consumer, and it removes only an
  already-unreliable capability. I'd be comfortable merging as-is (optionally noting it in a changelog).

  If you want to be conservative, two options                                 

  - Reorder: null after the emit (one line). _on_room_event is synchronous and emit is synchronous, so the null still completes before any other coroutine
  (including unpublish_track) can interleave — meaning the redundant-clear avoidance still holds, and the callback can read publication.track in the
  non-racy case (restoring the exact pre-PR behavior). This is the minimal way to make Devin's concern moot without changing the event signature.
  - Add the track as a second emit arg (like track_unsubscribed): gives reliable access, but it's a public event-signature change — bigger, and I would not
  fold that into this PR.                                                     

  My recommendation: leave it as-is (nulling before emit is the more honest semantic — "the track is gone"), since nothing consumes it. But if you'd rather
  not introduce any behavior delta at all, the after-emit reorder is a safe one-liner

I'm tempted to leave it alone since this very minor "breaking change" I think is actually now in practice more correct. But I'm open to alternate perspectives here.

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.

wouldn't the alternative be to simply null it after emitting the event? for sync event handlers that should keep the previous behaviour?

else:
logging.debug("local_track_unpublished for untracked publication sid %s", sid)
Expand All @@ -774,6 +784,15 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None:
del self.local_participant._track_publications[previous_sid]
republished._info = event.local_track_republished.info
self.local_participant._track_publications[republished.sid] = republished
if republished.track is not None:
# Keep the local-track invariant (track.sid == publication.sid,
# set at publish_track) intact across republish, then re-push
# metadata so any attached FrameProcessor learns the new
# publication SID / credentials. _set_room with the same room
# is a no-op for the token_refreshed listener but re-fans the
# metadata to every registered AudioStream.
republished.track._info.sid = republished.sid
republished.track._set_room(self)
Comment on lines +787 to +795

@1egoman 1egoman Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note to any reviewers: the Devin bot surfaced this case where when a full reconnect happens, tracks would not have their metadata kept up to date. I'm not sure if republished.track._info.sid = republished.sid is a good pattern, but wanted to give an opportunity for folks to weigh in on these final late breaking changes before merging.

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.

didn't dive too deep into this, just a thought: if it's about the metadata being outdated, shouldn't we override all of the track's info ?

self.emit("local_track_republished", republished, previous_sid)
elif which == "local_track_subscribed":
sid = event.local_track_subscribed.track_sid
Expand All @@ -799,17 +818,21 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None:
rpublication._subscribed = True
if track_info.kind == TrackKind.KIND_VIDEO:
remote_video_track = RemoteVideoTrack(owned_track_info)
remote_video_track._set_room(self)
rpublication._track = remote_video_track
self.emit("track_subscribed", remote_video_track, rpublication, rparticipant)
elif track_info.kind == TrackKind.KIND_AUDIO:
remote_audio_track = RemoteAudioTrack(owned_track_info)
remote_audio_track._set_room(self)
rpublication._track = remote_audio_track
self.emit("track_subscribed", remote_audio_track, rpublication, rparticipant)
elif which == "track_unsubscribed":
identity = event.track_unsubscribed.participant_identity
rparticipant = self._remote_participants[identity]
rpublication = rparticipant.track_publications[event.track_unsubscribed.track_sid]
rtrack = rpublication.track
if rtrack is not None:
rtrack._set_room(None)
rpublication._track = None
rpublication._subscribed = False
self.emit("track_unsubscribed", rtrack, rpublication, rparticipant)
Expand Down
84 changes: 83 additions & 1 deletion livekit-rtc/livekit/rtc/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,103 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import TYPE_CHECKING, List, Union
from __future__ import annotations

import weakref
from typing import TYPE_CHECKING, List, Optional, Union
from ._ffi_client import FfiHandle, FfiClient
from ._proto import ffi_pb2 as proto_ffi
from ._proto import track_pb2 as proto_track
from ._proto import stats_pb2 as proto_stats

if TYPE_CHECKING:
from .audio_source import AudioSource
from .audio_stream import AudioStream
from .room import Room
from .video_source import VideoSource


class Track:
def __init__(self, owned_info: proto_track.OwnedTrack):
self._info = owned_info.info
self._ffi_handle = FfiHandle(owned_info.handle.id)
self._room_ref: Optional[weakref.ref[Room]] = None
self._audio_streams: weakref.WeakSet[AudioStream] = weakref.WeakSet()

def _resolve_room(self) -> Optional[Room]:
return self._room_ref() if self._room_ref is not None else None

def _set_room(self, room: Optional[Room]) -> None:
old_room = self._resolve_room()
if old_room is None and room is None:
# Already roomless — nothing to detach and nothing to re-clear.
# Without this guard a second _set_room(None) (e.g. the unpublish /
# local_track_unpublished race calling it from both paths) would
# re-fire _on_*_cleared on every registered processor.
return
if old_room is not room:
if old_room is not None:
old_room.off("token_refreshed", self._on_room_token_refreshed)
if room is not None:
room.on("token_refreshed", self._on_room_token_refreshed)

self._room_ref = weakref.ref(room) if room is not None else None

for stream in self._audio_streams:
self._push_processor_metadata_to_stream(stream, room)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

def _on_room_token_refreshed(self) -> None:
room = self._resolve_room()
if room is None or room._token is None or room._server_url is None:
return
for stream in self._audio_streams:
if not stream._processor:
continue
stream._processor._on_credentials_updated(token=room._token, url=room._server_url)

def _push_processor_metadata_to_stream(self, stream: AudioStream, room: Optional[Room]) -> None:
if not stream._processor:
return

if room is None:
# track left a room - clear processor's room context
stream._processor._on_stream_info_cleared()
stream._processor._on_credentials_cleared()
return

identity = ""
pub_sid = ""
track_sid = self.sid
if track_sid:
for participant in room.remote_participants.values():
publication = participant.track_publications.get(track_sid)
if publication is not None:
identity, pub_sid = participant.identity, publication.sid
break
else:
local = room._local_participant
if local is not None:
for local_publication in local.track_publications.values():
if local_publication.sid == track_sid:
identity, pub_sid = local.identity, local_publication.sid
break

Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
stream._processor._on_stream_info_updated(
room_name=room.name,
participant_identity=identity,
publication_sid=pub_sid,
)
if room._token is not None and room._server_url is not None:
stream._processor._on_credentials_updated(token=room._token, url=room._server_url)

def _register_audio_stream(self, stream: AudioStream) -> None:
self._audio_streams.add(stream)
room = self._resolve_room()
if room is not None:
self._push_processor_metadata_to_stream(stream, room)

def _unregister_audio_stream(self, stream: AudioStream) -> None:
self._audio_streams.discard(stream)

@property
def sid(self) -> str:
Expand Down
Loading
Loading