Module livekit.agents.beta.translation.room_io
Classes
class TranslationRoomIO (session: TranslationSession,
room: rtc.Room,
*,
options: NotGivenOr[TranslationRoomOptions] = NOT_GIVEN)-
Expand source code
class TranslationRoomIO: """Wires a :class:`~livekit.agents.beta.translation.TranslationSession` to a room. Its own wiring rather than ``RoomIO``: that one serves an ``AgentSession``, reaching for turn timers, chat input and an away timer a translator has none of. What is left is the part a translator does use — link a track, take its audio, publish the translated track, and publish both transcripts. What it links is a *track*, not a participant: the transcript a translator publishes is attributed to a track on the wire, a participant may publish more than one, and the publisher's identity is read off the track rather than the other way round. """ def __init__( self, session: TranslationSession, room: rtc.Room, *, options: NotGivenOr[TranslationRoomOptions] = NOT_GIVEN, ) -> None: self._session, self._room = session, room self._options = options if is_given(options) else TranslationRoomOptions() self._track_sid = self._options.track if is_given(self._options.track) else None self._participant_identity = ( self._options.participant_identity if is_given(self._options.participant_identity) else None ) self._audio_input: _ParticipantAudioInputStream | None = None self._audio_output: _ParticipantAudioOutput | None = None self._input_tr_output: _ParticipantTranscriptionOutput | None = None self._output_tr_output: _ParticipantTranscriptionOutput | None = None self._tr_synchronizer: TranscriptSynchronizer | None = None self._pre_connect_audio_handler: PreConnectAudioHandler | None = None self._track_available_fut = asyncio.Future[_LinkedTrack]() self._room_connected_fut = asyncio.Future[None]() self._ready_fut = asyncio.Future[None]() self._init_atask: asyncio.Task[None] | None = None self._input_transcript_ch: aio.Chan[tuple[str, bool]] | None = None self._input_transcript_atask: asyncio.Task[None] | None = None self._tasks: set[asyncio.Task[Any]] = set() async def start(self) -> None: audio_input_options = self._options.get_audio_input_options() if audio_input_options: if audio_input_options.pre_connect_audio: self._pre_connect_audio_handler = PreConnectAudioHandler( room=self._room, timeout=audio_input_options.pre_connect_audio_timeout ) self._pre_connect_audio_handler.register() self._audio_input = _ParticipantAudioInputStream( self._room, track_source=[ rtc.TrackSource.SOURCE_MICROPHONE, rtc.TrackSource.SOURCE_SCREENSHARE_AUDIO, ], sample_rate=audio_input_options.sample_rate, num_channels=audio_input_options.num_channels, frame_size_ms=audio_input_options.frame_size_ms, noise_cancellation=audio_input_options.noise_cancellation, auto_gain_control=( audio_input_options.auto_gain_control if is_given(audio_input_options.auto_gain_control) else ( audio_input_options.noise_cancellation is None or callable(audio_input_options.noise_cancellation) ) ), pre_connect_audio_handler=self._pre_connect_audio_handler, ) audio_output_options = self._options.get_audio_output_options() if audio_output_options: self._audio_output = _ParticipantAudioOutput( self._room, sample_rate=audio_output_options.sample_rate, num_channels=audio_output_options.num_channels, track_publish_options=audio_output_options.track_publish_options, track_name=( audio_output_options.track_name if is_given(audio_output_options.track_name) else "roomio_audio" ), ) text_output_options = self._options.get_text_output_options() if text_output_options: # _input_tr_output is built in _init_task, once there is a track to stamp it with self._input_transcript_ch = aio.Chan[tuple[str, bool]]() self._input_transcript_atask = asyncio.create_task( self._forward_input_transcript(self._input_transcript_ch) ) self._output_tr_output = _ParticipantTranscriptionOutput( room=self._room, is_delta_stream=True, participant=None, next_in_chain=text_output_options.next_in_chain, json_format=text_output_options.json_format, ) if text_output_options.sync_transcription is not False and ( audio_output := self._audio_output or self._session.output.audio ): self._tr_synchronizer = TranscriptSynchronizer( next_in_chain_audio=audio_output, next_in_chain_text=self._output_tr_output, speed=text_output_options.transcription_speed_factor, ) self._room.on("track_published", self._on_track_published) self._room.on("participant_connected", self._on_participant_connected) self._room.on("participant_disconnected", self._on_participant_disconnected) self._room.on("connection_state_changed", self._on_connection_state_changed) if self._room.isconnected(): self._on_connection_state_changed(rtc.ConnectionState.CONN_CONNECTED) self._init_atask = asyncio.create_task(self._init_task()) if self.audio_input: self._session.input.audio = self.audio_input if self.audio_output: self._session.output.audio = self.audio_output if self.transcription_output: self._session.output.transcription = self.transcription_output self._session.on("user_input_transcribed", self._on_input_transcribed) async def aclose(self) -> None: self._room.off("track_published", self._on_track_published) self._room.off("participant_connected", self._on_participant_connected) self._room.off("participant_disconnected", self._on_participant_disconnected) self._room.off("connection_state_changed", self._on_connection_state_changed) self._session.off("user_input_transcribed", self._on_input_transcribed) if self._init_atask: await aio.cancel_and_wait(self._init_atask) if self._input_transcript_ch: self._input_transcript_ch.close() if self._input_transcript_atask: await aio.cancel_and_wait(self._input_transcript_atask) if self._pre_connect_audio_handler: await self._pre_connect_audio_handler.aclose() if self._audio_input: await self._audio_input.aclose() if self._tr_synchronizer: await self._tr_synchronizer.aclose() if self._input_tr_output: await self._input_tr_output.aclose() if self._output_tr_output: await self._output_tr_output.aclose() if self._audio_output: await self._audio_output.aclose() await aio.cancel_and_wait(*self._tasks) self._tasks.clear() @property def room(self) -> rtc.Room: return self._room @property def audio_input(self) -> AudioInput | None: return self._audio_input @property def audio_output(self) -> AudioOutput | None: if self._tr_synchronizer: return self._tr_synchronizer.audio_output return self._audio_output @property def transcription_output(self) -> TextOutput | None: if self._tr_synchronizer: return self._tr_synchronizer.text_output return self._output_tr_output @property def linked_track(self) -> _LinkedTrack | None: """The track being translated, with the participant publishing it.""" if not self._track_available_fut.done(): return None return self._track_available_fut.result() @property def linked_participant(self) -> rtc.RemoteParticipant | None: """The participant publishing the linked track.""" linked = self.linked_track return linked.participant if linked else None @property def subscribed_fut(self) -> asyncio.Future[None] | None: if self._audio_output: return self._audio_output.subscribed return None async def wait_for_ready(self) -> None: """Wait until the source track is linked and the translated track is published.""" await self._ready_fut def set_track(self, track_sid: str | None) -> None: """Translate another track. The publisher is read off the track, so nothing else has to be supplied. A track that has not been published yet is accepted: it is linked once it appears. Raises: ValueError: If ``track_sid`` names a publication that is not audio. """ if track_sid is None: self.unset_track() return linked = self.linked_track if linked is not None and linked.publication.sid == track_sid: # already on it, but pin it all the same: unpinned, a later search is free to # settle on another of the publisher's microphones self._track_sid = track_sid if self._audio_input: self._audio_input.set_track_sid(track_sid) return found: _LinkedTrack | None = None for participant in self._room.remote_participants.values(): if (publication := participant.track_publications.get(track_sid)) is not None: if publication.kind != rtc.TrackKind.KIND_AUDIO: # the model is handed audio; linking a camera would only close what is # playing and leave nothing in its place raise ValueError( f"track {track_sid} is {rtc.TrackKind.Name(publication.kind)}, not audio" ) found = _LinkedTrack(publication, participant) break if self._track_available_fut.done(): self._track_available_fut = asyncio.Future[_LinkedTrack]() self._track_sid = track_sid if found is not None: self._track_available_fut.set_result(found) self._apply_link(found) elif self._audio_input: # pinned now, so whatever the publisher is currently sending is dropped until # the named track appears and _on_track_published links it self._audio_input.set_track_sid(track_sid) def _apply_link(self, linked: _LinkedTrack) -> None: """Point the input and the transcript at a linked track.""" self._participant_identity = linked.participant.identity track_sid = linked.publication.sid if self._audio_input: # pinned before the participant, so set_participant's scan of existing # publications cannot latch a different audio track of theirs first self._audio_input.set_track_sid(track_sid) self._audio_input.set_participant(self._participant_identity) if self._input_tr_output: self._input_tr_output.set_participant(self._participant_identity) # the transcript follows the track, or later speech keeps the old stamp self._input_tr_output.set_track(track_sid) logger.info( "TranslationRoomIO linked to track", extra={ trace_types.ATTR_TRACK_SID: track_sid, trace_types.ATTR_PARTICIPANT_IDENTITY: self._participant_identity, trace_types.ATTR_ROOM_NAME: self._room.name, }, ) def unset_track(self) -> None: """Stop translating any track, leaving the session with nothing to hear.""" self._track_sid = None self._participant_identity = None self._track_available_fut = asyncio.Future[_LinkedTrack]() if self._audio_input: self._audio_input.set_track_sid(None) self._audio_input.set_participant(None) if self._input_tr_output: self._input_tr_output.set_participant(None) self._input_tr_output.set_track(None) @log_exceptions(logger=logger) async def _init_task(self) -> None: await self._room_connected_fut for participant in self._room.remote_participants.values(): self._on_participant_connected(participant) linked = await self._track_available_fut if self._options.get_text_output_options() is not None: # stamped with the track itself, not whichever microphone track its publisher # happens to own, and attributed to that publisher on the wire self._input_tr_output = _ParticipantTranscriptionOutput( room=self._room, is_delta_stream=False, participant=linked.participant.identity, track=linked.publication.sid, ) self._apply_link(linked) if self._output_tr_output: self._output_tr_output.set_participant(self._room.local_participant.identity) if self._audio_output: await self._audio_output.start() if not self._ready_fut.done(): self._ready_fut.set_result(None) @log_exceptions(logger=logger) async def _forward_input_transcript(self, event_ch: aio.Chan[tuple[str, bool]]) -> None: async for transcript, is_final in event_ch: if self._input_tr_output is None: continue await self._input_tr_output.capture_text(transcript) if is_final: self._input_tr_output.flush() def _on_input_transcribed(self, ev: Any) -> None: if self._input_transcript_ch: self._input_transcript_ch.send_nowait((ev.transcript, ev.is_final)) def _on_connection_state_changed(self, state: rtc.ConnectionState.ValueType) -> None: if self._room.isconnected() and not self._room_connected_fut.done(): self._room_connected_fut.set_result(None) def _on_participant_connected(self, participant: rtc.RemoteParticipant) -> None: for publication in participant.track_publications.values(): self._on_track_published(publication, participant) def _on_track_published( self, publication: rtc.RemoteTrackPublication, participant: rtc.RemoteParticipant ) -> None: if self._track_available_fut.done() or publication.kind != rtc.TrackKind.KIND_AUDIO: return # a named track is taken whoever published it; otherwise the search is narrowed the # way RoomIO narrows its participant search if self._track_sid is not None: if publication.sid != self._track_sid: return else: if publication.source != rtc.TrackSource.SOURCE_MICROPHONE: return if self._participant_identity is not None: if participant.identity != self._participant_identity: return elif ( participant.attributes.get(ATTRIBUTE_PUBLISH_ON_BEHALF) == self._room.local_participant.identity ): return accepted_kinds = ( self._options.participant_kinds if is_given(self._options.participant_kinds) else DEFAULT_PARTICIPANT_KINDS ) if participant.kind not in accepted_kinds: return linked = _LinkedTrack(publication, participant) self._track_available_fut.set_result(linked) if self._init_atask is not None and self._init_atask.done(): # startup has finished, so nothing else will pick this up self._apply_link(linked) def _on_participant_disconnected(self, participant: rtc.RemoteParticipant) -> None: if not (linked := self.linked_participant) or participant.identity != linked.identity: return self._track_available_fut = asyncio.Future[_LinkedTrack]() if ( self._options.close_on_disconnect and participant.disconnect_reason in DEFAULT_CLOSE_ON_DISCONNECT_REASONS ): logger.info( "closing translation session due to participant disconnect " "(disable via `TranslationRoomOptions.close_on_disconnect=False`)", extra={ trace_types.ATTR_ROOM_NAME: self._room.name, trace_types.ATTR_PARTICIPANT_IDENTITY: participant.identity, }, ) self._session._close_soon(reason=CloseReason.PARTICIPANT_DISCONNECTED)Wires a :class:
~livekit.agents.beta.translation.TranslationSessionto a room.Its own wiring rather than
RoomIO: that one serves anAgentSession, reaching for turn timers, chat input and an away timer a translator has none of. What is left is the part a translator does use — link a track, take its audio, publish the translated track, and publish both transcripts.What it links is a track, not a participant: the transcript a translator publishes is attributed to a track on the wire, a participant may publish more than one, and the publisher's identity is read off the track rather than the other way round.
Instance variables
prop audio_input : AudioInput | None-
Expand source code
@property def audio_input(self) -> AudioInput | None: return self._audio_input prop audio_output : AudioOutput | None-
Expand source code
@property def audio_output(self) -> AudioOutput | None: if self._tr_synchronizer: return self._tr_synchronizer.audio_output return self._audio_output prop linked_participant : rtc.RemoteParticipant | None-
Expand source code
@property def linked_participant(self) -> rtc.RemoteParticipant | None: """The participant publishing the linked track.""" linked = self.linked_track return linked.participant if linked else NoneThe participant publishing the linked track.
prop linked_track : _LinkedTrack | None-
Expand source code
@property def linked_track(self) -> _LinkedTrack | None: """The track being translated, with the participant publishing it.""" if not self._track_available_fut.done(): return None return self._track_available_fut.result()The track being translated, with the participant publishing it.
prop room : rtc.Room-
Expand source code
@property def room(self) -> rtc.Room: return self._room prop subscribed_fut : asyncio.Future[None] | None-
Expand source code
@property def subscribed_fut(self) -> asyncio.Future[None] | None: if self._audio_output: return self._audio_output.subscribed return None prop transcription_output : TextOutput | None-
Expand source code
@property def transcription_output(self) -> TextOutput | None: if self._tr_synchronizer: return self._tr_synchronizer.text_output return self._output_tr_output
Methods
async def aclose(self) ‑> None-
Expand source code
async def aclose(self) -> None: self._room.off("track_published", self._on_track_published) self._room.off("participant_connected", self._on_participant_connected) self._room.off("participant_disconnected", self._on_participant_disconnected) self._room.off("connection_state_changed", self._on_connection_state_changed) self._session.off("user_input_transcribed", self._on_input_transcribed) if self._init_atask: await aio.cancel_and_wait(self._init_atask) if self._input_transcript_ch: self._input_transcript_ch.close() if self._input_transcript_atask: await aio.cancel_and_wait(self._input_transcript_atask) if self._pre_connect_audio_handler: await self._pre_connect_audio_handler.aclose() if self._audio_input: await self._audio_input.aclose() if self._tr_synchronizer: await self._tr_synchronizer.aclose() if self._input_tr_output: await self._input_tr_output.aclose() if self._output_tr_output: await self._output_tr_output.aclose() if self._audio_output: await self._audio_output.aclose() await aio.cancel_and_wait(*self._tasks) self._tasks.clear() def set_track(self, track_sid: str | None) ‑> None-
Expand source code
def set_track(self, track_sid: str | None) -> None: """Translate another track. The publisher is read off the track, so nothing else has to be supplied. A track that has not been published yet is accepted: it is linked once it appears. Raises: ValueError: If ``track_sid`` names a publication that is not audio. """ if track_sid is None: self.unset_track() return linked = self.linked_track if linked is not None and linked.publication.sid == track_sid: # already on it, but pin it all the same: unpinned, a later search is free to # settle on another of the publisher's microphones self._track_sid = track_sid if self._audio_input: self._audio_input.set_track_sid(track_sid) return found: _LinkedTrack | None = None for participant in self._room.remote_participants.values(): if (publication := participant.track_publications.get(track_sid)) is not None: if publication.kind != rtc.TrackKind.KIND_AUDIO: # the model is handed audio; linking a camera would only close what is # playing and leave nothing in its place raise ValueError( f"track {track_sid} is {rtc.TrackKind.Name(publication.kind)}, not audio" ) found = _LinkedTrack(publication, participant) break if self._track_available_fut.done(): self._track_available_fut = asyncio.Future[_LinkedTrack]() self._track_sid = track_sid if found is not None: self._track_available_fut.set_result(found) self._apply_link(found) elif self._audio_input: # pinned now, so whatever the publisher is currently sending is dropped until # the named track appears and _on_track_published links it self._audio_input.set_track_sid(track_sid)Translate another track.
The publisher is read off the track, so nothing else has to be supplied. A track that has not been published yet is accepted: it is linked once it appears.
Raises
ValueError- If
track_sidnames a publication that is not audio.
async def start(self) ‑> None-
Expand source code
async def start(self) -> None: audio_input_options = self._options.get_audio_input_options() if audio_input_options: if audio_input_options.pre_connect_audio: self._pre_connect_audio_handler = PreConnectAudioHandler( room=self._room, timeout=audio_input_options.pre_connect_audio_timeout ) self._pre_connect_audio_handler.register() self._audio_input = _ParticipantAudioInputStream( self._room, track_source=[ rtc.TrackSource.SOURCE_MICROPHONE, rtc.TrackSource.SOURCE_SCREENSHARE_AUDIO, ], sample_rate=audio_input_options.sample_rate, num_channels=audio_input_options.num_channels, frame_size_ms=audio_input_options.frame_size_ms, noise_cancellation=audio_input_options.noise_cancellation, auto_gain_control=( audio_input_options.auto_gain_control if is_given(audio_input_options.auto_gain_control) else ( audio_input_options.noise_cancellation is None or callable(audio_input_options.noise_cancellation) ) ), pre_connect_audio_handler=self._pre_connect_audio_handler, ) audio_output_options = self._options.get_audio_output_options() if audio_output_options: self._audio_output = _ParticipantAudioOutput( self._room, sample_rate=audio_output_options.sample_rate, num_channels=audio_output_options.num_channels, track_publish_options=audio_output_options.track_publish_options, track_name=( audio_output_options.track_name if is_given(audio_output_options.track_name) else "roomio_audio" ), ) text_output_options = self._options.get_text_output_options() if text_output_options: # _input_tr_output is built in _init_task, once there is a track to stamp it with self._input_transcript_ch = aio.Chan[tuple[str, bool]]() self._input_transcript_atask = asyncio.create_task( self._forward_input_transcript(self._input_transcript_ch) ) self._output_tr_output = _ParticipantTranscriptionOutput( room=self._room, is_delta_stream=True, participant=None, next_in_chain=text_output_options.next_in_chain, json_format=text_output_options.json_format, ) if text_output_options.sync_transcription is not False and ( audio_output := self._audio_output or self._session.output.audio ): self._tr_synchronizer = TranscriptSynchronizer( next_in_chain_audio=audio_output, next_in_chain_text=self._output_tr_output, speed=text_output_options.transcription_speed_factor, ) self._room.on("track_published", self._on_track_published) self._room.on("participant_connected", self._on_participant_connected) self._room.on("participant_disconnected", self._on_participant_disconnected) self._room.on("connection_state_changed", self._on_connection_state_changed) if self._room.isconnected(): self._on_connection_state_changed(rtc.ConnectionState.CONN_CONNECTED) self._init_atask = asyncio.create_task(self._init_task()) if self.audio_input: self._session.input.audio = self.audio_input if self.audio_output: self._session.output.audio = self.audio_output if self.transcription_output: self._session.output.transcription = self.transcription_output self._session.on("user_input_transcribed", self._on_input_transcribed) def unset_track(self) ‑> None-
Expand source code
def unset_track(self) -> None: """Stop translating any track, leaving the session with nothing to hear.""" self._track_sid = None self._participant_identity = None self._track_available_fut = asyncio.Future[_LinkedTrack]() if self._audio_input: self._audio_input.set_track_sid(None) self._audio_input.set_participant(None) if self._input_tr_output: self._input_tr_output.set_participant(None) self._input_tr_output.set_track(None)Stop translating any track, leaving the session with nothing to hear.
async def wait_for_ready(self) ‑> None-
Expand source code
async def wait_for_ready(self) -> None: """Wait until the source track is linked and the translated track is published.""" await self._ready_futWait until the source track is linked and the translated track is published.
class TranslationRoomOptions (audio_input: AudioInputOptions | bool = True,
audio_output: AudioOutputOptions | bool = True,
text_output: TextOutputOptions | bool = True,
track: NotGivenOr[str] = NOT_GIVEN,
participant_identity: NotGivenOr[str] = NOT_GIVEN,
participant_kinds: NotGivenOr[list[rtc.ParticipantKind.ValueType]] = NOT_GIVEN,
close_on_disconnect: bool = True)-
Expand source code
@dataclass class TranslationRoomOptions: """How a :class:`~livekit.agents.beta.translation.TranslationSession` reaches a room. The subset of ``RoomOptions`` a translator can use: it hears one participant and speaks its translation back into the room, and there is no text input to answer. """ audio_input: AudioInputOptions | bool = True """The speech to translate. Pass ``False`` to feed the session audio yourself.""" audio_output: AudioOutputOptions | bool = True """The track the translation is spoken on. Pass ``False`` to take the audio yourself.""" text_output: TextOutputOptions | bool = True """Transcripts published to the room: what was heard, and what was spoken back.""" track: NotGivenOr[str] = NOT_GIVEN """The sid of the audio track to translate. If not given, the first eligible one published. A track is the unit here rather than a participant: a participant can publish more than one, and only a track can be transcribed and attributed on the wire.""" participant_identity: NotGivenOr[str] = NOT_GIVEN """Narrows the automatic search to one participant's tracks. Ignored when ``track`` names a publication outright.""" participant_kinds: NotGivenOr[list[rtc.ParticipantKind.ValueType]] = NOT_GIVEN """Participant kinds accepted when searching automatically.""" close_on_disconnect: bool = True """Close the session when the linked track's publisher leaves of their own accord.""" def get_audio_input_options(self) -> AudioInputOptions | None: if isinstance(self.audio_input, AudioInputOptions): return self.audio_input return AudioInputOptions() if self.audio_input else None def get_audio_output_options(self) -> AudioOutputOptions | None: if isinstance(self.audio_output, AudioOutputOptions): return self.audio_output return AudioOutputOptions() if self.audio_output else None def get_text_output_options(self) -> TextOutputOptions | None: if isinstance(self.text_output, TextOutputOptions): return self.text_output return TextOutputOptions() if self.text_output else NoneHow a :class:
~livekit.agents.beta.translation.TranslationSessionreaches a room.The subset of
RoomOptionsa translator can use: it hears one participant and speaks its translation back into the room, and there is no text input to answer.Instance variables
var audio_input : livekit.agents.voice.room_io.types.AudioInputOptions | bool-
The speech to translate. Pass
Falseto feed the session audio yourself. var audio_output : livekit.agents.voice.room_io.types.AudioOutputOptions | bool-
The track the translation is spoken on. Pass
Falseto take the audio yourself. var close_on_disconnect : bool-
Close the session when the linked track's publisher leaves of their own accord.
var participant_identity : str | livekit.agents.types.NotGiven-
Narrows the automatic search to one participant's tracks. Ignored when
tracknames a publication outright. var participant_kinds : list[int] | livekit.agents.types.NotGiven-
Participant kinds accepted when searching automatically.
var text_output : livekit.agents.voice.room_io.types.TextOutputOptions | bool-
Transcripts published to the room: what was heard, and what was spoken back.
var track : str | livekit.agents.types.NotGiven-
The sid of the audio track to translate. If not given, the first eligible one published. A track is the unit here rather than a participant: a participant can publish more than one, and only a track can be transcribed and attributed on the wire.
Methods
def get_audio_input_options(self) ‑> livekit.agents.voice.room_io.types.AudioInputOptions | None-
Expand source code
def get_audio_input_options(self) -> AudioInputOptions | None: if isinstance(self.audio_input, AudioInputOptions): return self.audio_input return AudioInputOptions() if self.audio_input else None def get_audio_output_options(self) ‑> livekit.agents.voice.room_io.types.AudioOutputOptions | None-
Expand source code
def get_audio_output_options(self) -> AudioOutputOptions | None: if isinstance(self.audio_output, AudioOutputOptions): return self.audio_output return AudioOutputOptions() if self.audio_output else None def get_text_output_options(self) ‑> livekit.agents.voice.room_io.types.TextOutputOptions | None-
Expand source code
def get_text_output_options(self) -> TextOutputOptions | None: if isinstance(self.text_output, TextOutputOptions): return self.text_output return TextOutputOptions() if self.text_output else None