Module livekit.agents.beta.translation
Sub-modules
livekit.agents.beta.translation.room_iolivekit.agents.beta.translation.translation_modellivekit.agents.beta.translation.translation_session
Classes
class InputSpeechStarted-
Expand source code
@dataclass class InputSpeechStarted: """The speaker began a turn, as the model heard it."""The speaker began a turn, as the model heard it.
class InputSpeechStopped-
Expand source code
@dataclass class InputSpeechStopped: """The speaker ended a turn, as the model heard it."""The speaker ended a turn, as the model heard it.
class InputTranscript (text: str,
is_final: bool,
language: str | None = None,
speaker_id: str | None = None)-
Expand source code
@dataclass class InputTranscript: """What the model heard, in the language it was spoken.""" text: str is_final: bool language: str | None = None speaker_id: str | None = NoneWhat the model heard, in the language it was spoken.
Instance variables
var is_final : boolvar language : str | Nonevar speaker_id : str | Nonevar text : str
class OutputSpeechStopped-
Expand source code
@dataclass class OutputSpeechStopped: """The model finished speaking a translation; its audio up to here is one segment. Yielded by :attr:`TranslationModelSession.audio_stream` rather than emitted as an event, so it cannot overtake audio the model has already queued ahead of it. """The model finished speaking a translation; its audio up to here is one segment.
Yielded by :attr:
TranslationModelSession.audio_streamrather than emitted as an event, so it cannot overtake audio the model has already queued ahead of it. class OutputTranscript (text: str, is_final: bool, language: str | None = None)-
Expand source code
@dataclass class OutputTranscript: """What the model said, in the language it translates into.""" text: str is_final: bool language: str | None = NoneWhat the model said, in the language it translates into.
Instance variables
var is_final : boolvar language : str | Nonevar text : str
class TranslatedAudioFrame (frame: rtc.AudioFrame, language: str | None = None)-
Expand source code
@dataclass class TranslatedAudioFrame: """One frame of the translation the model speaks.""" frame: rtc.AudioFrame language: str | None = None """The language spoken in this frame, for a provider that stamps its audio."""One frame of the translation the model speaks.
Instance variables
var frame : AudioFramevar language : str | None-
The language spoken in this frame, for a provider that stamps its audio.
class TranslationCapabilities (input_transcription: bool = False,
output_transcription: bool = False,
turn_reporting: bool = False,
output_segmentation: bool = False,
retargetable: bool = False)-
Expand source code
@dataclass class TranslationCapabilities: """What varies between translation providers.""" input_transcription: bool = False """Whether the model transcribes the speech it hears, in the language it was spoken.""" output_transcription: bool = False """Whether the model transcribes the translation it speaks.""" turn_reporting: bool = False """Whether the model reports where the speaker's turns start and end. About the speech it hears. For the speech it speaks, see ``output_segmentation``. """ output_segmentation: bool = False """Whether the model marks where each translation it speaks ends. When it does, the session ends a playout segment on that mark alone. When it does not, the session falls back to a silence timer, which cannot tell a pause inside a sentence from the end of one. """ retargetable: bool = False """Whether the target language can be changed on a running session."""What varies between translation providers.
Instance variables
var input_transcription : bool-
Whether the model transcribes the speech it hears, in the language it was spoken.
var output_segmentation : bool-
Whether the model marks where each translation it speaks ends.
When it does, the session ends a playout segment on that mark alone. When it does not, the session falls back to a silence timer, which cannot tell a pause inside a sentence from the end of one.
var output_transcription : bool-
Whether the model transcribes the translation it speaks.
var retargetable : bool-
Whether the target language can be changed on a running session.
var turn_reporting : bool-
Whether the model reports where the speaker's turns start and end.
About the speech it hears. For the speech it speaks, see
output_segmentation.
class TranslationModel (*,
capabilities: TranslationCapabilities,
target_language: str,
source_language: str | None = None)-
Expand source code
class TranslationModel(ABC): """A model that hears speech in one language and speaks it in another. Its own primitive, beside :class:`~livekit.agents.llm.LLM`, :class:`~livekit.agents.llm.RealtimeModel` and :class:`~livekit.agents.llm.DuplexModel`: a translator holds no conversation, so it takes no instructions, carries no chat context, calls no tools and never decides to reply. What it has instead is a language it translates into. Run one with :class:`~livekit.agents.beta.translation.TranslationSession`. """ def __init__( self, *, capabilities: TranslationCapabilities, target_language: str, source_language: str | None = None, ) -> None: """ Args: capabilities: What this provider can do. target_language: The language the model speaks its translation in. source_language: The language the model expects to hear, or ``None`` when it detects the language itself. """ self._capabilities = capabilities self._target_language = target_language self._source_language = source_language self._label = f"{type(self).__module__}.{type(self).__name__}" @property def model(self) -> str: return "unknown" @property def provider(self) -> str: return "unknown" @property def capabilities(self) -> TranslationCapabilities: return self._capabilities @property def label(self) -> str: return self._label @property def target_language(self) -> str: """The language the model speaks its translation in.""" return self._target_language @property def source_language(self) -> str | None: """The language the model expects to hear, or ``None`` when it detects it itself.""" return self._source_language def update_options(self, *, target_language: str) -> None: """Point the model at another language. Takes effect on the sessions opened after it. To retarget a running session, call :meth:`TranslationModelSession.update_options` on the session itself, which a provider only serves when ``capabilities.retargetable``. """ self._target_language = target_language @abstractmethod def session(self) -> TranslationModelSession: """Open a session; the caller pushes audio into it and reads its translation out.""" @abstractmethod async def aclose(self) -> None: ... async def __aenter__(self) -> TranslationModel: return self async def __aexit__( self, exc_type: type[BaseException] | None, exc: BaseException | None, exc_tb: TracebackType | None, ) -> None: await self.aclose()A model that hears speech in one language and speaks it in another.
Its own primitive, beside :class:
~livekit.agents.llm.LLM, :class:~livekit.agents.llm.RealtimeModeland :class:~livekit.agents.llm.DuplexModel: a translator holds no conversation, so it takes no instructions, carries no chat context, calls no tools and never decides to reply. What it has instead is a language it translates into. Run one with :class:~livekit.agents.beta.translation.TranslationSession.Args
capabilities- What this provider can do.
target_language- The language the model speaks its translation in.
source_language- The language the model expects to hear, or
Nonewhen it detects the language itself.
Ancestors
- abc.ABC
Instance variables
prop capabilities : TranslationCapabilities-
Expand source code
@property def capabilities(self) -> TranslationCapabilities: return self._capabilities prop label : str-
Expand source code
@property def label(self) -> str: return self._label prop model : str-
Expand source code
@property def model(self) -> str: return "unknown" prop provider : str-
Expand source code
@property def provider(self) -> str: return "unknown" prop source_language : str | None-
Expand source code
@property def source_language(self) -> str | None: """The language the model expects to hear, or ``None`` when it detects it itself.""" return self._source_languageThe language the model expects to hear, or
Nonewhen it detects it itself. prop target_language : str-
Expand source code
@property def target_language(self) -> str: """The language the model speaks its translation in.""" return self._target_languageThe language the model speaks its translation in.
Methods
async def aclose(self) ‑> None-
Expand source code
@abstractmethod async def aclose(self) -> None: ... def session(self) ‑> TranslationModelSession-
Expand source code
@abstractmethod def session(self) -> TranslationModelSession: """Open a session; the caller pushes audio into it and reads its translation out."""Open a session; the caller pushes audio into it and reads its translation out.
def update_options(self, *, target_language: str) ‑> None-
Expand source code
def update_options(self, *, target_language: str) -> None: """Point the model at another language. Takes effect on the sessions opened after it. To retarget a running session, call :meth:`TranslationModelSession.update_options` on the session itself, which a provider only serves when ``capabilities.retargetable``. """ self._target_language = target_languagePoint the model at another language.
Takes effect on the sessions opened after it. To retarget a running session, call :meth:
TranslationModelSession.update_options()on the session itself, which a provider only serves whencapabilities.retargetable.
class TranslationModelError (**data: Any)-
Expand source code
class TranslationModelError(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True) type: Literal["translation_model_error"] = "translation_model_error" timestamp: float label: str error: Exception = Field(..., exclude=True) recoverable: boolUsage Documentation
A base class for creating Pydantic models.
Attributes
__class_vars__- The names of the class variables defined on the model.
__private_attributes__- Metadata about the private attributes of the model.
__signature__- The synthesized
__init__[Signature][inspect.Signature] of the model. __pydantic_complete__- Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__- The core schema of the model.
__pydantic_custom_init__- Whether the model has a custom
__init__function. __pydantic_decorators__- Metadata containing the decorators defined on the model.
This replaces
Model.__validators__andModel.__root_validators__from Pydantic V1. __pydantic_generic_metadata__- A dictionary containing metadata about generic Pydantic models.
The
originandargsitems map to the [__origin__][genericalias.origin] and [__args__][genericalias.args] attributes of [generic aliases][types-genericalias], and theparameteritem maps to the__parameter__attribute of generic classes. __pydantic_parent_namespace__- Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__- The name of the post-init method for the model, if defined.
__pydantic_root_model__- Whether the model is a [
RootModel][pydantic.root_model.RootModel]. __pydantic_serializer__- The
pydantic-coreSchemaSerializerused to dump instances of the model. __pydantic_validator__- The
pydantic-coreSchemaValidatorused to validate instances of the model. __pydantic_fields__- A dictionary of field names and their corresponding [
FieldInfo][pydantic.fields.FieldInfo] objects. __pydantic_computed_fields__- A dictionary of computed field names and their corresponding [
ComputedFieldInfo][pydantic.fields.ComputedFieldInfo] objects. __pydantic_extra__- A dictionary containing extra values, if [
extra][pydantic.config.ConfigDict.extra] is set to'allow'. __pydantic_fields_set__- The names of fields explicitly set during instantiation.
__pydantic_private__- Values of private attributes set on the model instance.
Create a new model by parsing and validating input data from keyword arguments.
Raises [
ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var error : Exceptionvar label : strvar model_configvar recoverable : boolvar timestamp : floatvar type : Literal['translation_model_error']
class TranslationModelSession (translation_model: TranslationModel)-
Expand source code
class TranslationModelSession( ABC, rtc.EventEmitter[TranslationEventTypes | TEvent], Generic[TEvent] ): """One run of a :class:`TranslationModel`: audio in, translated audio out. The speech the model hears is pushed in frame by frame with :meth:`push_audio`; the translation it speaks is read out of :attr:`audio_stream`, which also carries an :class:`OutputSpeechStopped` where each spoken translation ends. What it heard and what it said arrive as ``input_transcript`` and ``output_transcript`` events, and the speaker's turns as ``input_speech_started`` and ``input_speech_stopped``. """ def __init__(self, translation_model: TranslationModel) -> None: super().__init__() self._translation_model = translation_model self._closing = False @property def translation_model(self) -> TranslationModel: return self._translation_model @property def capabilities(self) -> TranslationCapabilities: return self._translation_model.capabilities @property @abstractmethod def audio_stream(self) -> AsyncIterable[TranslatedAudioFrame | OutputSpeechStopped]: """The translation the model speaks, and where each one ends. A model whose ``capabilities.output_segmentation`` is set yields an :class:`OutputSpeechStopped` after the last frame of each translation. Because it travels in the stream, it is always read after the audio it closes. """ @abstractmethod def push_audio(self, frame: rtc.AudioFrame) -> None: """Hand the model one frame of the speech to translate.""" def clear_audio(self) -> None: """Drop the translation the model has not spoken yet. A no-op for a provider that cannot discard what it has already produced. """ def update_options(self, *, target_language: str) -> None: """Point this running session at another language. Raises: RuntimeError: If the provider fixes the target language for the session's life. """ raise RuntimeError( f"{self._translation_model.label} translates into a language fixed for the " "session's life; start a new session to translate into another" ) @abstractmethod async def aclose(self) -> None: ...One run of a :class:
TranslationModel: audio in, translated audio out.The speech the model hears is pushed in frame by frame with :meth:
push_audio; the translation it speaks is read out of :attr:audio_stream, which also carries an :class:OutputSpeechStoppedwhere each spoken translation ends. What it heard and what it said arrive asinput_transcriptandoutput_transcriptevents, and the speaker's turns asinput_speech_startedandinput_speech_stopped.Ancestors
- abc.ABC
- EventEmitter
- typing.Generic
Instance variables
prop audio_stream : AsyncIterable[TranslatedAudioFrame | OutputSpeechStopped]-
Expand source code
@property @abstractmethod def audio_stream(self) -> AsyncIterable[TranslatedAudioFrame | OutputSpeechStopped]: """The translation the model speaks, and where each one ends. A model whose ``capabilities.output_segmentation`` is set yields an :class:`OutputSpeechStopped` after the last frame of each translation. Because it travels in the stream, it is always read after the audio it closes. """The translation the model speaks, and where each one ends.
A model whose
capabilities.output_segmentationis set yields an :class:OutputSpeechStoppedafter the last frame of each translation. Because it travels in the stream, it is always read after the audio it closes. prop capabilities : TranslationCapabilities-
Expand source code
@property def capabilities(self) -> TranslationCapabilities: return self._translation_model.capabilities prop translation_model : TranslationModel-
Expand source code
@property def translation_model(self) -> TranslationModel: return self._translation_model
Methods
async def aclose(self) ‑> None-
Expand source code
@abstractmethod async def aclose(self) -> None: ... def clear_audio(self) ‑> None-
Expand source code
def clear_audio(self) -> None: """Drop the translation the model has not spoken yet. A no-op for a provider that cannot discard what it has already produced. """Drop the translation the model has not spoken yet.
A no-op for a provider that cannot discard what it has already produced.
def push_audio(self, frame: rtc.AudioFrame) ‑> None-
Expand source code
@abstractmethod def push_audio(self, frame: rtc.AudioFrame) -> None: """Hand the model one frame of the speech to translate."""Hand the model one frame of the speech to translate.
def update_options(self, *, target_language: str) ‑> None-
Expand source code
def update_options(self, *, target_language: str) -> None: """Point this running session at another language. Raises: RuntimeError: If the provider fixes the target language for the session's life. """ raise RuntimeError( f"{self._translation_model.label} translates into a language fixed for the " "session's life; start a new session to translate into another" )Point this running session at another language.
Raises
RuntimeError- If the provider fixes the target language for the session's life.
Inherited members
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
class TranslationSession (*,
model: TranslationModel,
userdata: NotGivenOr[Userdata_T] = NOT_GIVEN,
output_segment_timeout: float = 0.5,
loop: asyncio.AbstractEventLoop | None = None)-
Expand source code
class TranslationSession(rtc.EventEmitter[EventTypes], Generic[Userdata_T]): """Runs a :class:`TranslationModel` against a room: speech in, translation out. Its own primitive rather than an ``AgentSession``: a translator holds no conversation. It takes no instructions, keeps no chat context, calls no tools, and never decides to reply — so there is no ``Agent`` to supply and no turn to take. What is left it does carry: room I/O, both transcripts, user and agent state, and a close that reports why. A model reports the speaker's turns itself, so no VAD runs here, and nothing waits for the speaker to stop before speaking over them. Example: ```python session = TranslationSession(model=VoiceModel(target_language="es")) await session.start(room=ctx.room) ``` """ def __init__( self, *, model: TranslationModel, userdata: NotGivenOr[Userdata_T] = NOT_GIVEN, output_segment_timeout: float = DEFAULT_OUTPUT_SEGMENT_TIMEOUT, loop: asyncio.AbstractEventLoop | None = None, ) -> None: """ Args: model: The translation model this session runs. userdata: Arbitrary state carried on the session. output_segment_timeout: Seconds of silence from the model that end a segment of its speech. Used only when the model's ``capabilities.output_segmentation`` is unset; a model that marks its own boundaries ends a segment on the mark, so a pause inside a sentence does not split it. loop: Event loop to run on. """ super().__init__() self._loop = loop or asyncio.get_event_loop() self._model = model self._userdata: Userdata_T | None = userdata if is_given(userdata) else None self._output_segment_timeout = output_segment_timeout self._input = io.AgentInput(self._on_video_input_changed, self._on_audio_input_changed) self._output = io.AgentOutput( self._on_video_output_changed, self._on_audio_output_changed, self._on_text_output_changed, ) self._room_io: TranslationRoomIO | None = None self._model_session: TranslationModelSession | None = None self._user_state: UserState = "listening" self._started = False self._closing_task: asyncio.Task[None] | None = None self._job_context_cb_registered = False self._lock = asyncio.Lock() self._forward_input_atask: asyncio.Task[None] | None = None self._forward_output_atask: asyncio.Task[None] | None = None self._output_transcript_ch: aio.Chan[OutputTranscript] | None = None self._output_transcript_atask: asyncio.Task[None] | None = None self._segment_timer: asyncio.TimerHandle | None = None self._segment_tasks: set[asyncio.Task[None]] = set() # serialises capturing a frame against ending a segment, so a tail the resampler # holds cannot be flushed out from under a frame being captured self._output_lock = asyncio.Lock() self._resampler: rtc.AudioResampler | None = None self._resampler_key: tuple[int, int, int | None] | None = None self._resampler_sink: io.AudioOutput | None = None @property def model(self) -> TranslationModel: """The translation model this session runs.""" return self._model @property def input(self) -> io.AgentInput: return self._input @property def output(self) -> io.AgentOutput: return self._output @property def user_state(self) -> UserState: return self._user_state @property def room_io(self) -> TranslationRoomIO | None: return self._room_io @property def userdata(self) -> Userdata_T: if self._userdata is None: raise ValueError("TranslationSession userdata is not set") return self._userdata @userdata.setter def userdata(self, value: Userdata_T) -> None: self._userdata = value def set_target_language(self, language: str) -> None: """Point the model at another language. Before :meth:`start` this only changes what the next session is opened with. On a running session it reaches the model, which a provider serves only when its ``capabilities.retargetable``. Raises: RuntimeError: If the session is running and its model cannot be retargeted. """ if self._model_session is not None and not self._model.capabilities.retargetable: raise RuntimeError( f"{self._model.label} translates into a language fixed for the session's " "life; start a new session to translate into another" ) # the running session first: it can refuse a language the capability allows in # general, and the model setting must not claim a language being spoken if self._model_session is not None: self._model_session.update_options(target_language=language) self._model.update_options(target_language=language) async def start( self, *, room: NotGivenOr[rtc.Room] = NOT_GIVEN, room_options: NotGivenOr[TranslationRoomOptions] = NOT_GIVEN, ) -> None: """Start translating. Args: room: The room to listen to and speak into. Omit it to drive the session's ``input``/``output`` yourself. room_options: How the session reaches that room. """ async with self._lock: if self._started: return job_ctx = get_job_context(required=False) if is_given(room) and self._room_io is None: options = room_options if is_given(room_options) else TranslationRoomOptions() if self.input.audio is not None and options.audio_input: logger.warning( "room audio input is enabled but input.audio is already set, ignoring.." ) options.audio_input = False if self.output.audio is not None and options.audio_output: logger.warning( "room audio output is enabled but output.audio is already set, ignoring.." ) options.audio_output = False self._room_io = TranslationRoomIO(self, room, options=options) await self._room_io.start() if job_ctx: await job_ctx.connect() if job_ctx and not self._job_context_cb_registered: job_ctx.add_shutdown_callback( lambda: self._aclose_impl(reason=CloseReason.JOB_SHUTDOWN) ) self._job_context_cb_registered = True self._model_session = self._model.session() self._model_session.on("input_speech_started", self._on_input_speech_started) self._model_session.on("input_speech_stopped", self._on_input_speech_stopped) self._model_session.on("input_transcript", self._on_input_transcript) self._model_session.on("output_transcript", self._on_output_transcript) self._model_session.on("error", self._on_model_error) self._output_transcript_ch = aio.Chan[OutputTranscript]() self._output_transcript_atask = asyncio.create_task( self._forward_output_transcript_task(self._output_transcript_ch), name="_forward_output_transcript_task", ) self._closing_task = None self._started = True if self.input.audio is not None: self._forward_input_atask = asyncio.create_task( self._forward_input_audio_task(), name="_forward_input_audio_task" ) self._forward_output_atask = asyncio.create_task( self._forward_output_audio_task(self._model_session), name="_forward_output_audio_task", ) async def aclose(self) -> None: """Stop translating and release the model and the room.""" await self._aclose_impl(reason=CloseReason.USER_INITIATED) def shutdown(self) -> None: """Close the session without waiting for it.""" self._close_soon(reason=CloseReason.USER_INITIATED) def _close_soon(self, *, reason: CloseReason) -> None: if self._closing_task: return self._closing_task = asyncio.create_task(self._aclose_impl(reason=reason)) @log_exceptions(logger=logger) async def _aclose_impl(self, *, reason: CloseReason) -> None: async with self._lock: if not self._started: return self._started = False self._cancel_segment_timer() if self._segment_tasks: await aio.cancel_and_wait(*self._segment_tasks) self._segment_tasks.clear() await aio.cancel_and_wait( *[ task for task in (self._forward_input_atask, self._forward_output_atask) if task is not None ] ) self._forward_input_atask = self._forward_output_atask = None if self._output_transcript_ch: self._output_transcript_ch.close() self._output_transcript_ch = None if self._output_transcript_atask: await aio.cancel_and_wait(self._output_transcript_atask) self._output_transcript_atask = None if self._model_session: await self._model_session.aclose() self._model_session = None # detach what room io made, since it closes those on the way out and a restart # would otherwise reuse the closed devices. A caller's own stay attached: they # were never closed here, and losing the reference would strand them if (room_io := self._room_io) is not None: if self.input.audio is room_io.audio_input: self.input.audio = None if self.output.audio is room_io.audio_output: self.output.audio = None if self.output.transcription is room_io.transcription_output: self.output.transcription = None self.emit("close", CloseEvent(reason=reason)) if self._room_io: await self._room_io.aclose() self._room_io = None self._discard_resampler() self._user_state = "listening" # -- model session -- @log_exceptions(logger=logger) async def _forward_input_audio_task(self) -> None: audio_input = self.input.audio if audio_input is None: return async for frame in audio_input: if (model_session := self._model_session) is not None: model_session.push_audio(frame) @log_exceptions(logger=logger) async def _forward_output_audio_task(self, model_session: TranslationModelSession) -> None: marks_segments = self._model.capabilities.output_segmentation try: async for ev in model_session.audio_stream: if isinstance(ev, OutputSpeechStopped): # read in order behind the audio it closes, so nothing it ended is # still waiting to be captured. Honoured even while the output is # muted: whatever was captured before the mute is still a segment, and # dropping the mark would merge the next translation into it await self._close_output_segment() continue if self.output.audio is None or not self.output.audio_enabled: continue async with self._output_lock: if (audio_output := self.output.audio) is None: continue await self._capture_output_frame(ev.frame, audio_output) if not marks_segments: self._arm_segment_timer() finally: self._cancel_segment_timer() async with self._output_lock: await self._drain_resampler() @log_exceptions(logger=logger) async def _forward_output_transcript_task(self, event_ch: aio.Chan[OutputTranscript]) -> None: async for ev in event_ch: transcription_output = self.output.transcription if transcription_output is None or not self.output.transcription_enabled: continue await transcription_output.capture_text(ev.text) if ev.is_final: transcription_output.flush() def _on_input_speech_started(self, _: InputSpeechStarted) -> None: self._update_user_state("speaking") def _on_input_speech_stopped(self, _: InputSpeechStopped) -> None: self._update_user_state("listening") def _on_input_transcript(self, ev: InputTranscript) -> None: self.emit( "user_input_transcribed", UserInputTranscribedEvent( transcript=ev.text, is_final=ev.is_final, language=ev.language, speaker_id=ev.speaker_id, ), ) def _on_output_transcript(self, ev: OutputTranscript) -> None: if self._output_transcript_ch is not None: self._output_transcript_ch.send_nowait(ev) self.emit( "translation_transcribed", TranslationTranscribedEvent( transcript=ev.text, is_final=ev.is_final, language=ev.language if ev.language is not None else self._model.target_language, ), ) def _on_model_error(self, ev: TranslationModelError) -> None: self.emit("error", ErrorEvent(error=ev, source=self._model)) if not ev.recoverable: logger.error( "translation model error, closing the session", extra={"label": ev.label, "error": str(ev.error)}, ) self._close_soon(reason=CloseReason.ERROR) # -- output segments -- async def _capture_output_frame( self, frame: rtc.AudioFrame, audio_output: io.AudioOutput ) -> None: """Hand one frame to the sink, at the rate that sink plays at. The resampler is rebuilt whenever the model's format or the sink changes, since one built for the old pair would tag frames with a rate the new one does not play. """ target_rate = audio_output.sample_rate key = (frame.sample_rate, frame.num_channels, target_rate) if self._resampler is not None and ( self._resampler_key != key or self._resampler_sink is not audio_output ): if self._resampler_sink is audio_output: # same sink, new format: its tail still belongs there, in this segment await self._drain_resampler() else: # the sink was replaced: its segment ends on the sink that received it logger.debug("audio output replaced, closing the segment it was given") await self._close_segment_locked() if target_rate is None or frame.sample_rate == target_rate: await audio_output.capture_frame(frame) return if self._resampler is None: self._resampler = rtc.AudioResampler( input_rate=frame.sample_rate, output_rate=target_rate, num_channels=frame.num_channels, ) self._resampler_key = key self._resampler_sink = audio_output for resampled in self._resampler.push(frame): await audio_output.capture_frame(resampled) async def _drain_resampler(self) -> None: """Push what the resampler still holds to the sink it was built for, then drop it. Not to whatever sink is current: those frames were converted for the old one, and another sink would replay them at its own rate. """ resampler, sink = self._resampler, self._resampler_sink self._discard_resampler() if resampler is None or sink is None: return for frame in resampler.flush(): await sink.capture_frame(frame) def _discard_resampler(self) -> None: """Forget the resampler and the pair it was built for, dropping anything it holds. Used where that tail cannot be placed, or once :meth:`_drain_resampler` has emptied it. """ self._resampler = None self._resampler_key = None self._resampler_sink = None async def _close_output_segment(self) -> None: """End the segment the model has been speaking into.""" self._cancel_segment_timer() async with self._output_lock: await self._close_segment_locked() async def _close_segment_locked(self) -> None: """End the segment on the sink that actually received its audio. Drains the resampler first: a partial frame it is still holding belongs to the segment being closed, not to whatever the model says next. The sink it was built for owns the segment, so replacing ``output.audio`` mid-translation closes the old sink's segment rather than flushing the replacement's. """ sink = self._resampler_sink or self.output.audio await self._drain_resampler() if sink is not None: sink.flush() def _arm_segment_timer(self) -> None: """Restart the silence that ends a segment, for a model that marks none itself. Armed after every frame, so the segment ends only once the model has gone quiet for ``output_segment_timeout``. """ self._cancel_segment_timer() self._segment_timer = self._loop.call_later( self._output_segment_timeout, self._close_output_segment_soon ) def _cancel_segment_timer(self) -> None: """Disarm the silence timer, leaving the current segment open.""" if self._segment_timer is not None: self._segment_timer.cancel() self._segment_timer = None def _close_output_segment_soon(self) -> None: """Close the segment from the timer, which cannot await the drain itself. The work runs as a task, tracked so closing the session can wait on it. """ self._segment_timer = None task = asyncio.create_task(self._close_output_segment()) self._segment_tasks.add(task) task.add_done_callback(self._segment_tasks.discard) # -- state -- def _update_user_state(self, state: UserState) -> None: if self._user_state == state: return old_state = self._user_state self._user_state = state self.emit("user_state_changed", UserStateChangedEvent(old_state=old_state, new_state=state)) # -- io -- def _on_audio_input_changed(self) -> None: if not self._started: return if self._forward_input_atask is not None: self._forward_input_atask.cancel() self._forward_input_atask = None if self.input.audio is not None: self._forward_input_atask = asyncio.create_task( self._forward_input_audio_task(), name="_forward_input_audio_task" ) def _on_video_input_changed(self) -> None: if self.input.video is not None: logger.warning("a translation model takes no video input, ignoring..") def _on_audio_output_changed(self) -> None: ... def _on_video_output_changed(self) -> None: ... def _on_text_output_changed(self) -> None: ...Runs a :class:
TranslationModelagainst a room: speech in, translation out.Its own primitive rather than an
AgentSession: a translator holds no conversation. It takes no instructions, keeps no chat context, calls no tools, and never decides to reply — so there is noAgentto supply and no turn to take. What is left it does carry: room I/O, both transcripts, user and agent state, and a close that reports why.A model reports the speaker's turns itself, so no VAD runs here, and nothing waits for the speaker to stop before speaking over them.
Example
session = TranslationSession(model=VoiceModel(target_language="es")) await session.start(room=ctx.room)Args
model- The translation model this session runs.
userdata- Arbitrary state carried on the session.
output_segment_timeout- Seconds of silence from the model that end a segment of
its speech. Used only when the model's
capabilities.output_segmentationis unset; a model that marks its own boundaries ends a segment on the mark, so a pause inside a sentence does not split it. loop- Event loop to run on.
Ancestors
- EventEmitter
- typing.Generic
Instance variables
prop input : io.AgentInput-
Expand source code
@property def input(self) -> io.AgentInput: return self._input prop model : TranslationModel-
Expand source code
@property def model(self) -> TranslationModel: """The translation model this session runs.""" return self._modelThe translation model this session runs.
prop output : io.AgentOutput-
Expand source code
@property def output(self) -> io.AgentOutput: return self._output prop room_io : TranslationRoomIO | None-
Expand source code
@property def room_io(self) -> TranslationRoomIO | None: return self._room_io prop user_state : UserState-
Expand source code
@property def user_state(self) -> UserState: return self._user_state prop userdata : Userdata_T-
Expand source code
@property def userdata(self) -> Userdata_T: if self._userdata is None: raise ValueError("TranslationSession userdata is not set") return self._userdata
Methods
async def aclose(self) ‑> None-
Expand source code
async def aclose(self) -> None: """Stop translating and release the model and the room.""" await self._aclose_impl(reason=CloseReason.USER_INITIATED)Stop translating and release the model and the room.
def set_target_language(self, language: str) ‑> None-
Expand source code
def set_target_language(self, language: str) -> None: """Point the model at another language. Before :meth:`start` this only changes what the next session is opened with. On a running session it reaches the model, which a provider serves only when its ``capabilities.retargetable``. Raises: RuntimeError: If the session is running and its model cannot be retargeted. """ if self._model_session is not None and not self._model.capabilities.retargetable: raise RuntimeError( f"{self._model.label} translates into a language fixed for the session's " "life; start a new session to translate into another" ) # the running session first: it can refuse a language the capability allows in # general, and the model setting must not claim a language being spoken if self._model_session is not None: self._model_session.update_options(target_language=language) self._model.update_options(target_language=language)Point the model at another language.
Before :meth:
startthis only changes what the next session is opened with. On a running session it reaches the model, which a provider serves only when itscapabilities.retargetable.Raises
RuntimeError- If the session is running and its model cannot be retargeted.
def shutdown(self) ‑> None-
Expand source code
def shutdown(self) -> None: """Close the session without waiting for it.""" self._close_soon(reason=CloseReason.USER_INITIATED)Close the session without waiting for it.
async def start(self,
*,
room: NotGivenOr[rtc.Room] = NOT_GIVEN,
room_options: NotGivenOr[TranslationRoomOptions] = NOT_GIVEN) ‑> None-
Expand source code
async def start( self, *, room: NotGivenOr[rtc.Room] = NOT_GIVEN, room_options: NotGivenOr[TranslationRoomOptions] = NOT_GIVEN, ) -> None: """Start translating. Args: room: The room to listen to and speak into. Omit it to drive the session's ``input``/``output`` yourself. room_options: How the session reaches that room. """ async with self._lock: if self._started: return job_ctx = get_job_context(required=False) if is_given(room) and self._room_io is None: options = room_options if is_given(room_options) else TranslationRoomOptions() if self.input.audio is not None and options.audio_input: logger.warning( "room audio input is enabled but input.audio is already set, ignoring.." ) options.audio_input = False if self.output.audio is not None and options.audio_output: logger.warning( "room audio output is enabled but output.audio is already set, ignoring.." ) options.audio_output = False self._room_io = TranslationRoomIO(self, room, options=options) await self._room_io.start() if job_ctx: await job_ctx.connect() if job_ctx and not self._job_context_cb_registered: job_ctx.add_shutdown_callback( lambda: self._aclose_impl(reason=CloseReason.JOB_SHUTDOWN) ) self._job_context_cb_registered = True self._model_session = self._model.session() self._model_session.on("input_speech_started", self._on_input_speech_started) self._model_session.on("input_speech_stopped", self._on_input_speech_stopped) self._model_session.on("input_transcript", self._on_input_transcript) self._model_session.on("output_transcript", self._on_output_transcript) self._model_session.on("error", self._on_model_error) self._output_transcript_ch = aio.Chan[OutputTranscript]() self._output_transcript_atask = asyncio.create_task( self._forward_output_transcript_task(self._output_transcript_ch), name="_forward_output_transcript_task", ) self._closing_task = None self._started = True if self.input.audio is not None: self._forward_input_atask = asyncio.create_task( self._forward_input_audio_task(), name="_forward_input_audio_task" ) self._forward_output_atask = asyncio.create_task( self._forward_output_audio_task(self._model_session), name="_forward_output_audio_task", )Start translating.
Args
room- The room to listen to and speak into. Omit it to drive the session's
input/outputyourself. room_options- How the session reaches that room.
Inherited members
class TranslationTranscribedEvent (**data: Any)-
Expand source code
class TranslationTranscribedEvent(BaseModel): """The translation the model spoke, in the language it translates into.""" type: Literal["translation_transcribed"] = "translation_transcribed" transcript: str is_final: bool language: str | None = None created_at: float = Field(default_factory=time.time)The translation the model spoke, in the language it translates into.
Create a new model by parsing and validating input data from keyword arguments.
Raises [
ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var created_at : floatvar is_final : boolvar language : str | Nonevar model_configvar transcript : strvar type : Literal['translation_transcribed']