Files
2026-07-13 13:39:38 +08:00

456 lines
16 KiB
Python

"""Realtime speech translation with captions and voice output.
This example demonstrates how to build a multi-user meeting where each participant
can speak in their own language, and be able to understand the other participants.
It works by:
- Each participant contains an attribute indicating theirlanguage
(this is embedded in their access token)
- The agent keeps track of the languages needed for each participant, and creates
translation tasks for each input audio track.
- In each translation task, we use an LLM to translate to the target language, and
synthesize the translated audio.
- The translated audio and transcriptions are published to the room.
- The audio track is named as "{input_track_id}-{target_language_code}"
- The transcription is published as a text stream, with an attribute "language"
set to the target language.
- With the above, the UI can render the right captions and audio tracks matching the
language that the participant is speaking.
"""
import asyncio
import logging
from dataclasses import dataclass
from dotenv import load_dotenv
from livekit import rtc
from livekit.agents import (
AgentServer,
AutoSubscribe,
JobContext,
JobRequest,
cli,
llm,
stt,
tokenize,
utils,
voice,
)
from livekit.agents.types import (
ATTRIBUTE_TRANSCRIPTION_FINAL,
ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID,
ATTRIBUTE_TRANSCRIPTION_TRACK_ID,
TOPIC_TRANSCRIPTION,
)
from livekit.plugins import deepgram, elevenlabs, google
load_dotenv()
logger = logging.getLogger("transcriber")
@dataclass
class Language:
code: str
name: str
_languages = [
Language(code="en", name="English"),
Language(code="de", name="German"),
Language(code="es", name="Spanish"),
Language(code="fr", name="French"),
Language(code="ja", name="Japanese"),
Language(code="zh", name="Chinese (Mandarin)"),
]
language_map: dict[str, Language] = {lang.code: lang for lang in _languages}
@dataclass
class PlayoutData:
audio_ch: utils.aio.Chan[rtc.AudioFrame]
transcript: str
def __init__(self, transcript: str):
self.audio_ch = utils.aio.Chan[rtc.AudioFrame]()
self.transcript = transcript
class Translator:
"""Handles real-time translation of transcribed text to target languages."""
def __init__(
self,
*,
room: rtc.Room,
track_id: str,
target_language: Language,
participant_identity: str,
):
"""Initialize translator for a specific language."""
self._room = room
self._target_language = target_language
self._track_id = track_id # source track id
self._participant_identity = participant_identity
self._tasks: list[asyncio.Task] = []
self._input_ch = utils.aio.Chan[str]()
self._playout_ch = utils.aio.Chan[PlayoutData]()
self._llm_stream = utils.aio.Chan[str]()
self._llm = google.LLM()
self._tts = elevenlabs.TTS()
self._resampler = rtc.AudioResampler(22050, 48000)
def start(self):
self._tasks.append(asyncio.create_task(self._run()))
self._tasks.append(asyncio.create_task(self._synthesize_tts()))
self._tasks.append(asyncio.create_task(self._publish_and_playout()))
async def aclose(self):
self.end_input()
await asyncio.gather(*self._tasks)
self._tasks.clear()
def push_sentence(self, sentence: str):
self._input_ch.send_nowait(sentence)
def end_input(self):
if not self._input_ch.closed:
self._input_ch.close()
@utils.log_exceptions(logger=logger)
async def _run(self):
"""Translate sentences to target language and enqueue the translated audio frames."""
async for sentence in self._input_ch:
if not sentence:
continue
context = llm.ChatContext()
context.add_message(
role="system",
content=f"You are a translator for language: {self._target_language.name}. "
f"Your only response should be the exact translation of input text in the {self._target_language.name} language.",
)
context.add_message(role="user", content=sentence)
try:
llm_stream = self._llm.chat(chat_ctx=context)
response = ""
async for chunk in llm_stream:
if not chunk.delta or not chunk.delta.content:
continue
response += chunk.delta.content
await llm_stream.aclose()
logger.info(f"translated to {self._target_language.name}: {response}")
self._llm_stream.send_nowait(response)
except Exception:
logger.exception("Error translating sentence")
self._llm_stream.close()
@utils.log_exceptions(logger=logger)
async def _synthesize_tts(self):
async for sentence in self._llm_stream:
stream = self._tts.synthesize(sentence)
playout_data = PlayoutData(sentence)
self._playout_ch.send_nowait(playout_data)
async for frame in stream:
frames = self._resampler.push(frame.frame)
for f in frames:
playout_data.audio_ch.send_nowait(f)
playout_data.audio_ch.close()
await stream.aclose()
logger.info("ending tts input")
self._playout_ch.close()
@utils.log_exceptions(logger=logger)
async def _publish_and_playout(self):
"""Publish the translated audio frames to the room and play them out."""
audio_output = voice._ParticipantAudioOutput(
self._room,
sample_rate=48000,
num_channels=1,
track_publish_options=rtc.TrackPublishOptions(),
track_name=f"{self._track_id}-{self._target_language.code}",
)
await audio_output.start()
text_output = voice._ParticipantStreamTranscriptionOutput(
self._room,
participant=self._participant_identity,
is_delta_stream=True,
attributes={
"language": self._target_language.code,
"translated": "true",
},
)
synchronizer = voice.TranscriptSynchronizer(
next_in_chain_audio=audio_output,
next_in_chain_text=text_output,
)
async for playout_data in self._playout_ch:
logger.info(f"translated playing out: {playout_data.transcript}")
await synchronizer.text_output.capture_text(playout_data.transcript)
synchronizer.text_output.flush()
async for frame in playout_data.audio_ch:
await synchronizer.audio_output.capture_frame(frame)
synchronizer.audio_output.flush()
await synchronizer.aclose()
await audio_output.aclose()
class InputTrack:
def __init__(
self,
*,
language: str,
track: rtc.RemoteAudioTrack,
participant_identity: str,
room: rtc.Room,
):
self.language = language
self.track = track
self.participant_identity = participant_identity
self.room = room
self._translators: dict[str, Translator] = {}
self._tasks: list[asyncio.Task] = []
self._stt = deepgram.STT(language=language, model="nova-2")
tokenizer = tokenize.blingfire.SentenceTokenizer()
self._sentence_stream: tokenize.SentenceStream = tokenizer.stream()
self._stt_stream = self._stt.stream(language=language)
def start(self):
self._tasks.append(asyncio.create_task(self._consume_input()))
self._tasks.append(asyncio.create_task(self._tokenize_to_sentences()))
self._tasks.append(asyncio.create_task(self._forward_to_translators()))
async def aclose(self):
await utils.aio.cancel_and_wait(*self._tasks)
self._tasks.clear()
for translator in self._translators.values():
await translator.aclose()
def set_languages(self, target_languages: list[str], *, room: rtc.Room):
for lang in target_languages:
if lang != self.language:
self._add_translator(lang, room)
for lang in list(self._translators.keys()):
if lang != self.language and lang not in target_languages:
self._remove_translator(lang)
def _add_translator(self, target_language: str, room: rtc.Room):
lang = language_map.get(target_language)
if not lang:
return
if lang.code in self._translators:
return
logger.info(f"starting translator for {self.track.sid} - {lang.name}")
translator = Translator(
room=room,
target_language=lang,
track_id=self.track.sid,
participant_identity=self.participant_identity,
)
translator.start()
self._translators[lang.code] = translator
def _remove_translator(self, target_language: str):
translator = self._translators.pop(target_language, None)
if translator:
asyncio.create_task(translator.aclose())
async def _forward_to_translators(self):
"""Forward transcribed sentences to each language specific translators."""
async for ev in self._sentence_stream:
logger.info(f"forwarding to translators: {ev.token}")
for translator in self._translators.values():
translator.push_sentence(ev.token)
logger.info("ending translator input")
for translator in self._translators.values():
translator.end_input()
@utils.log_exceptions(logger=logger)
async def _tokenize_to_sentences(self):
"""tokenize STT output to sentences."""
async def _create_text_writer(*, segment_id: str) -> rtc.TextStreamWriter:
attributes = {
ATTRIBUTE_TRANSCRIPTION_FINAL: "false",
ATTRIBUTE_TRANSCRIPTION_TRACK_ID: self.track.sid,
ATTRIBUTE_TRANSCRIPTION_SEGMENT_ID: segment_id,
"language": self.language,
}
return await self.room.local_participant.stream_text(
topic=TOPIC_TRANSCRIPTION,
sender_identity=self.participant_identity,
attributes=attributes,
)
segment_id = utils.shortuuid("SG_")
writer: rtc.TextStreamWriter | None = None
async for ev in self._stt_stream:
if ev.type == stt.SpeechEventType.FINAL_TRANSCRIPT:
logger.info(f"STT: {ev.alternatives[0].text}")
self._sentence_stream.push_text(ev.alternatives[0].text)
if writer is None:
writer = await _create_text_writer(segment_id=segment_id)
await writer.write(ev.alternatives[0].text)
elif ev.type == stt.SpeechEventType.END_OF_SPEECH:
self._sentence_stream.flush()
if writer:
attributes = {
ATTRIBUTE_TRANSCRIPTION_FINAL: "true",
ATTRIBUTE_TRANSCRIPTION_TRACK_ID: self.track.sid,
}
await writer.aclose(attributes=attributes)
writer = None
segment_id = utils.shortuuid("SG_")
self._sentence_stream.end_input()
async def _consume_input(self):
"""Transcribe the audio stream and run through STT."""
stream = rtc.AudioStream.from_track(track=self.track)
async for ev in stream:
self._stt_stream.push_frame(ev.frame)
await stream.aclose()
class RoomTranslator:
"""Translates audio tracks in a room to multiple languages, publishing audio and transcriptions back to the room."""
def __init__(self, room: rtc.Room, *, additional_languages: list[str] | None = None):
"""Initialize the room translator.
additional_languages: list[str] = [] - additional languages to translate to, in addition to the languages of the participants in the room.
"""
self.room = room
self.additional_languages = list(additional_languages) if additional_languages else []
self.desired_languages = list(self.additional_languages)
self.input_tracks: list[InputTrack] = []
def start(self):
@self.room.on("participant_connected")
def on_participant_joined(participant: rtc.RemoteParticipant):
self._update_languages()
@self.room.on("participant_disconnected")
def on_participant_left(participant: rtc.RemoteParticipant):
self._update_languages()
@self.room.on("track_subscribed")
def on_track_subscribed(
track: rtc.Track,
publication: rtc.TrackPublication,
participant: rtc.RemoteParticipant,
):
if track.kind == rtc.TrackKind.KIND_AUDIO:
audio_language = participant.attributes.get("language")
# TODO: this is test code
if audio_language is None:
audio_language = "en"
self._add_track(
track, language=audio_language, participant_identity=participant.identity
)
@self.room.on("track_unsubscribed")
def on_track_unsubscribed(
track: rtc.Track,
publication: rtc.TrackPublication,
participant: rtc.RemoteParticipant,
):
if track.kind == rtc.TrackKind.KIND_AUDIO:
self._remove_track(track)
# first set to the current state that's already in the room
self._update_languages()
existing_track_ids = [input_track.track.sid for input_track in self.input_tracks]
for participant in self.room.remote_participants.values():
for pub in participant.track_publications.values():
if (
pub.track
and pub.kind == rtc.TrackKind.KIND_AUDIO
and pub.sid not in existing_track_ids
):
on_track_subscribed(pub.track, pub, participant)
def _update_languages(self):
languages = set(self.additional_languages)
for participant in self.room.remote_participants.values():
language = participant.attributes.get("language")
if language:
languages.add(language)
self.desired_languages = list(languages)
self._reconcile_translators()
def _add_track(self, track: rtc.RemoteAudioTrack, *, language: str, participant_identity: str):
input_track = InputTrack(
language=language,
track=track,
participant_identity=participant_identity,
room=self.room,
)
input_track.start()
self.input_tracks.append(input_track)
self._reconcile_translators()
def _remove_track(self, track: rtc.RemoteAudioTrack):
input_track = next((t for t in self.input_tracks if t.track.sid == track.sid), None)
if input_track:
asyncio.create_task(input_track.aclose())
self.input_tracks.remove(input_track)
def _reconcile_translators(self):
for track in self.input_tracks:
track.set_languages(self.desired_languages, room=self.room)
async def request_fnc(req: JobRequest):
await req.accept(
name="agent",
identity="agent",
)
server = AgentServer()
@server.rtc_session(agent_name="translator", on_request=request_fnc)
async def entrypoint(ctx: JobContext):
"""Main entrypoint for the translation agent service."""
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
# set the current agent's state to be compatible with new sandbox
await ctx.room.local_participant.set_attributes(
{
"lk.agent.state": "listening",
}
)
logger.info("agent state set to listening")
room_translator = RoomTranslator(
ctx.room,
# for testing, uncomment to add additional languages to translate to
# additional_languages=[],
)
room_translator.start()
if __name__ == "__main__":
cli.run_app(server)