Module livekit.agents.beta.translation

Sub-modules

livekit.agents.beta.translation.room_io
livekit.agents.beta.translation.translation_model
livekit.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 = None

What the model heard, in the language it was spoken.

Instance variables

var is_final : bool
var language : str | None
var speaker_id : str | None
var 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_stream rather 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 = None

What the model said, in the language it translates into.

Instance variables

var is_final : bool
var language : str | None
var 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 : AudioFrame
var 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.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.

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.

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_language

The language the model expects to hear, or None when 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_language

The 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_language

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.

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: bool

Usage Documentation

Models

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__ and Model.__root_validators__ from Pydantic V1.
__pydantic_generic_metadata__
A dictionary containing metadata about generic Pydantic models. The origin and args items map to the [__origin__][genericalias.origin] and [__args__][genericalias.args] attributes of [generic aliases][types-genericalias], and the parameter item 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-core SchemaSerializer used to dump instances of the model.
__pydantic_validator__
The pydantic-core SchemaValidator used 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.

self is explicitly positional-only to allow self as a field name.

Ancestors

  • pydantic.main.BaseModel

Class variables

var error : Exception
var label : str
var model_config
var recoverable : bool
var timestamp : float
var 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: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.

Ancestors

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_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.

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

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 None

The 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_sid names 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_fut

Wait 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 None

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.

Instance variables

var audio_input : livekit.agents.voice.room_io.types.AudioInputOptions | bool

The speech to translate. Pass False to feed the session audio yourself.

var audio_output : livekit.agents.voice.room_io.types.AudioOutputOptions | bool

The track the translation is spoken on. Pass False to 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 track names 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: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

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_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.

Ancestors

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._model

The 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: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.
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/output yourself.
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.

self is explicitly positional-only to allow self as a field name.

Ancestors

  • pydantic.main.BaseModel

Class variables

var created_at : float
var is_final : bool
var language : str | None
var model_config
var transcript : str
var type : Literal['translation_transcribed']