LiveKit 통합

LiveKit Agents 워커를 사용해 LiveKit 룸을 Speech Engine에 연결하세요.

이 가이드에서는 ElevenLabs Speech Engine을 LiveKit 룸의 음성 레이어로 사용하는 방법을 보여줍니다. LiveKit Agents 워커는 참여자로서 룸에 입장하고, 사용자의 오디오 트랙을 구독하며, Speech Engine에 WebSocket을 열고, Speech Engine이 합성한 오디오를 자체 트랙으로 룸에 다시 게시합니다.

아키텍처

Speech Engine은 두 종류의 WebSocket 연결을 지원합니다.

  • ElevenLabs API가 연결하는 브레인 WebSocket입니다. 서버에서 Speech Engine SDK(engine.serve() / engine.attach())로 이를 실행하며, 응답할 트랜스크립트를 수신합니다.
  • 클라이언트가 연결하는 대화 WebSocket입니다. 브라우저는 WebRTC 토큰으로 연결하며, LiveKit Agents 워커와 같은 비브라우저 클라이언트는 서명된 URL로 연결하여 원시 PCM 오디오를 양방향으로 스트리밍합니다.

LiveKit 워커는 두 번째 연결을 사용합니다. LiveKit 룸 참여자를 대신하여 Speech Engine의 “클라이언트” 역할을 합니다.

loop [Conversation] Join room (LiveKit token) Join room (dispatched) Open conversation WebSocket (signed URL) Microphone audio (Opus) Decoded PCM frames user_audio_chunk (base64 PCM) user_transcript agent_response (streamed) audio (base64 PCM) Publish PCM frames Audio (Opus) Browser LiveKit Room Agents Worker ElevenLabs (conversation WS) Brain Server

브레인 서버는 Speech Engine 빠른 시작과 동일합니다. LiveKit 워커가 브라우저를 오디오 소스로 대체하지만 LLM 로직은 그대로 유지됩니다.

이 패턴을 사용할 때

룸 자체가 경험의 일부라면 LiveKit 브리지를 사용하세요.

  • 사용자가 에이전트와 함께 서로 대화하는 다중 참여자 세션
  • 전송 방식을 바꾸면 클라이언트가 작동하지 않는 기존 LiveKit 배포 환경
  • 화면 공유, 비디오 또는 텍스트 채팅과 룸을 공유하는 음성 에이전트
  • 통화 중인 AI 에이전트가 필요한 SIP-to-LiveKit 전달 통화

다른 참여자 없이 브라우저와 Speech Engine 간의 음성 루프만 필요하다면 Speech Engine 빠른 시작의 WebRTC 클라이언트가 더 간단합니다. Speech Engine은 브라우저와 직접 WebRTC로 통신하므로 LiveKit 룸이 필요하지 않습니다.

사전 요구 사항

  • LiveKit 프로젝트(LiveKit Cloud 또는 자체 호스팅 서버). 워커에는 LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET가 필요합니다.
  • ElevenLabs Speech Engine. Speech Engine 빠른 시작에 따라 생성하고 브레인 서버를 실행하세요.
  • Python 3.9+ 또는 Node.js 18+.

Node 브리지 워커는 현재 Developer Preview인 @livekit/rtc-node를 사용합니다. 프로덕션 배포에는 Python 워커 사용을 권장합니다.

Speech Engine 오디오 형식 구성

LiveKit의 AudioStream은 수신 Opus 트랙을 요청한 PCM 샘플 레이트로 리샘플링하므로 Speech Engine의 입력과 직접 일치시킬 수 있습니다. Speech Engine이 ASR 입력으로 16kHz PCM을 받고 TTS 출력으로 24kHz PCM을 내보내도록 업데이트하세요.

import asyncio
import os
from elevenlabs import AsyncElevenLabs
elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
async def update_engine():
await elevenlabs.speech_engine.update(
speech_engine_id="seng_8k3m9xr4hjnfg983brhmhkd98n6",
asr={"user_input_audio_format": "pcm_16000"},
tts={"agent_output_audio_format": "pcm_24000"},
)
asyncio.run(update_engine())

Speech Engine PCM은 전체적으로 부호 있는 16비트 리틀엔디언 형식입니다. 지원되는 다른 레이트는 오디오 형식 레퍼런스를 참조하세요.

브리지 워커 빌드

워커는 LiveKit 서버에 연결하고 작업을 기다린 뒤 할당된 룸에 입장하여 룸과 Speech Engine 사이의 오디오를 브리지하는 장기 실행 프로세스입니다.

1

종속성 설치

pip install "livekit-agents" "livekit-api" "elevenlabs" "aiohttp" "python-dotenv"
2

Speech Engine 서명 URL 생성

워커는 Speech Engine 대화 WebSocket용 단기 서명 URL을 요청합니다. 서명 URL에는 엔진 ID와 일회성 서명이 포함되므로 API 키를 노출하지 않고도 워커가 WebSocket을 열 수 있습니다.

from elevenlabs import AsyncElevenLabs
elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
async def signed_url() -> str:
response = await elevenlabs.conversational_ai.conversations.get_signed_url(
agent_id=os.environ["SPEECH_ENGINE_ID"],
)
return response.signed_url
3

워커 진입점 정의

워커가 룸에 할당될 때마다 진입점이 실행됩니다. 진입점은 룸에 연결하고 Speech Engine 대화 WebSocket을 연 다음, Speech Engine으로 전송되는 발신자 오디오와 다시 수신되는 합성 오디오를 위한 두 개의 오디오 브리지를 시작합니다.

import asyncio
import base64
import json
import os
import aiohttp
from dotenv import load_dotenv
from elevenlabs import AsyncElevenLabs
from livekit import agents, rtc
from livekit.agents import JobContext, WorkerOptions, cli
load_dotenv()
elevenlabs = AsyncElevenLabs(api_key=os.environ["ELEVENLABS_API_KEY"])
SPEECH_ENGINE_ID = os.environ["SPEECH_ENGINE_ID"]
USER_INPUT_RATE = 16000
AGENT_OUTPUT_RATE = 24000
async def signed_url() -> str:
response = await elevenlabs.conversational_ai.conversations.get_signed_url(
agent_id=SPEECH_ENGINE_ID,
)
return response.signed_url
async def entrypoint(ctx: JobContext):
el_ws_ready: asyncio.Future[aiohttp.ClientWebSocketResponse] = (
asyncio.get_running_loop().create_future()
)
async def pump_user_audio(track: rtc.Track):
el_ws = await el_ws_ready
stream = rtc.AudioStream(
track, sample_rate=USER_INPUT_RATE, num_channels=1,
)
async for event in stream:
payload = base64.b64encode(bytes(event.frame.data)).decode()
await el_ws.send_str(json.dumps({"user_audio_chunk": payload}))
# Register the subscriber BEFORE ctx.connect() so we don't miss tracks
# that get auto-subscribed during the connection handshake.
@ctx.room.on("track_subscribed")
def on_track_subscribed(track, publication, participant):
if track.kind != rtc.TrackKind.KIND_AUDIO:
return
if participant.identity == ctx.room.local_participant.identity:
return
asyncio.create_task(pump_user_audio(track))
await ctx.connect()
# Publish a track for the agent's synthesized audio.
source = rtc.AudioSource(sample_rate=AGENT_OUTPUT_RATE, num_channels=1)
track = rtc.LocalAudioTrack.create_audio_track("elevenlabs-agent", source)
await ctx.room.local_participant.publish_track(
track,
rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE),
)
# Open the Speech Engine conversation WebSocket.
http = aiohttp.ClientSession()
el_ws = await http.ws_connect(await signed_url())
await el_ws.send_str(json.dumps({"type": "conversation_initiation_client_data"}))
el_ws_ready.set_result(el_ws)
async def el_to_room():
async for msg in el_ws:
if msg.type != aiohttp.WSMsgType.TEXT:
continue
event = json.loads(msg.data)
etype = event.get("type")
if etype == "audio":
pcm = base64.b64decode(event["audio_event"]["audio_base_64"])
samples_per_channel = len(pcm) // 2
frame = rtc.AudioFrame(
pcm, AGENT_OUTPUT_RATE, 1, samples_per_channel,
)
await source.capture_frame(frame)
elif etype == "interruption":
source.clear_queue()
elif etype == "ping":
event_id = event.get("ping_event", {}).get("event_id")
await el_ws.send_str(json.dumps({
"type": "pong", "event_id": event_id,
}))
pump_task = asyncio.create_task(el_to_room())
async def cleanup():
pump_task.cancel()
await el_ws.close()
await http.close()
ctx.add_shutdown_callback(cleanup)
if __name__ == "__main__":
cli.run_app(WorkerOptions(
entrypoint_fnc=entrypoint,
agent_name="elevenlabs-bridge",
))

워커는 track_subscribed 핸들러에서 로컬 참여자의 ID와 비교하여 자신이 게시한 오디오를 필터링합니다. 이 확인이 없으면 워커는 자체 합성 오디오를 Speech Engine으로 다시 전송하려고 시도합니다.

올바른 작동을 위해 다음 두 가지 순서 관련 사항이 중요합니다.

  • 리스너 타이밍: TrackSubscribed는 ctx.connect() 전에 등록됩니다. LiveKit은 연결 핸드셰이크 중 기존 트랙을 자동 구독하므로, 이후에 등록한 리스너는 이벤트를 놓칠 수 있습니다. 오디오 펌프는 Speech Engine WebSocket의 Future / Promise를 기다리므로 즉시 구독하고 연결이 열리는 즉시 오디오를 전달할 수 있습니다.
  • TypeScript 전용 — 캡처 직렬화: @livekit/rtc-node의 AudioSource.captureFrame은 동시에 호출하면 InvalidState를 발생시킵니다. TypeScript 핸들러는 프로미스 체인으로 캡처를 직렬화합니다. Python의 단일 async for el_to_room 루프는 본래 순차적이므로 이 작업이 필요하지 않습니다.
4

워커 시작

python bridge.py dev

dev는 핫 리로드와 컬러 로그를 활성화합니다. 프로덕션에서는 JSON 로그와 정상 종료를 위해 start를 사용하세요.

워커는 LiveKit 서버에 연결하고 작업 할당을 기다립니다. 할당되기 전까지는 어떤 룸에도 입장하지 않습니다.

워커를 룸에 할당

워커에 agent_name이 있으므로 명시적 할당을 사용합니다. 백엔드가 지시할 때만 룸에 입장합니다. 가장 간단한 패턴은 브라우저가 연결에 사용하는 LiveKit 액세스 토큰에 RoomAgentDispatch를 포함하는 것입니다.

import os
from dotenv import load_dotenv
from flask import Flask, jsonify, request
from livekit.api import AccessToken, RoomAgentDispatch, VideoGrants
load_dotenv()
app = Flask(**name**)
@app.route("/api/livekit-token")
def get_token():
room_name = request.args.get("room", "demo-room")
identity = request.args.get("identity", "web-user")
token = (
AccessToken(
os.environ["LIVEKIT_API_KEY"],
os.environ["LIVEKIT_API_SECRET"],
)
.with_identity(identity)
.with_grants(VideoGrants(room_join=True, room=room_name))
.with_room_config(
room_configuration={
"agents": [RoomAgentDispatch(agent_name="elevenlabs-bridge")],
},
)
)
return jsonify(token=token.to_jwt(), url=os.environ["LIVEKIT_URL"])
if **name** == "**main**":
app.run(port=3002)

브라우저가 이 토큰을 사용해 룸을 생성하거나 입장하면 LiveKit이 동일한 룸으로 브리지 워커를 자동 할당합니다.

브라우저에서 연결

브라우저에는 표준 LiveKit 클라이언트만 필요하며 Speech Engine과 직접 상호작용하지 않습니다.

App.tsx
import { Room, RoomEvent, Track } from "livekit-client";
import { useCallback, useState } from "react";
export default function App() {
const [room] = useState(() => new Room());
const join = useCallback(async () => {
const response = await fetch("/api/livekit-token");
const { token, url } = await response.json();
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Audio) {
document.body.appendChild(track.attach());
}
});
await room.connect(url, token);
await room.localParticipant.setMicrophoneEnabled(true);
}, [room]);
return <button onClick={join}>Start conversation</button>;
}

버튼을 클릭하면 브라우저는 LiveKit 토큰을 가져오고 마이크를 활성화한 상태로 룸에 입장하며 에이전트의 오디오 트랙 수신을 시작합니다. 워커가 할당되어 Speech Engine 세션을 열고 양방향으로 오디오를 브리지합니다.

오디오 형식 레퍼런스

Speech Engine은 다음 오디오 형식을 지원합니다. 엔진에서 asr.user_input_audio_format 및 tts.agent_output_audio_format을 통해 구성하세요.

형식샘플 레이트인코딩참고
pcm_80008kHz부호 있는 16비트 LE PCMASR 입력 전용.
pcm_1600016kHz부호 있는 16비트 LE PCMLiveKit 사용자 입력에 권장.
pcm_2205022.05kHz부호 있는 16비트 LE PCM
pcm_2400024kHz부호 있는 16비트 LE PCMLiveKit 에이전트 출력에 권장.
pcm_4410044.1kHz부호 있는 16비트 LE PCMTTS 출력에는 Independent Publisher 등급 이상이 필요합니다.
pcm_4800048kHz부호 있는 16비트 LE PCMASR 입력 전용.
ulaw_80008kHzμ-lawTwilio Media Streams에서 사용됩니다.

LiveKit의 AudioStream 및 AudioSource가 리샘플링을 처리하므로 AudioStream에서 어떤 샘플 레이트든 요청할 수 있으며 SDK가 기본 48kHz Opus 트랙에서 변환합니다.

프로덕션 고려 사항

  • 명시적 할당: 항상 WorkerOptions에 agent_name / agentName을 설정하세요. 자동 할당은 LiveKit 프로젝트에서 생성되는 모든 룸에 워커를 실행하며, 이는 일반적으로 원하는 동작이 아닙니다.
  • 브레인 서버 인증: Speech Engine에 공유 시크릿을 설정하고 브레인 서버에서 이를 검증하여 Speech Engine만 엔드포인트에 도달할 수 있도록 하세요.
    await elevenlabs.speech_engine.update(
    speech_engine_id="seng_8k3m9xr4hjnfg983brhmhkd98n6",
    speech_engine={"request_headers": {"x-api-key": os.environ["SHARED_SECRET"]}},
    )
    그러면 브레인 서버는 WebSocket 업그레이드를 수락하기 전에 request.headers["x-api-key"]를 확인합니다.
  • 토큰 서버: LiveKit 및 Speech Engine 토큰은 서버 측에서 생성하세요. LIVEKIT_API_SECRET 또는 ELEVENLABS_API_KEY를 브라우저에 절대 노출하지 마세요.
  • 이벤트 루프 관리: CPU 바운드 작업은 워커의 이벤트 루프에서 분리하세요. AudioSource.capture_frame 및 AudioStream 반복은 시간에 민감하므로 긴 동기 호출은 중단 이벤트를 지연시키거나 누락시킬 수 있습니다. 차단 작업에는 asyncio.to_thread()(Python) 또는 worker_threads(Node)를 사용하세요.
  • 종료: ctx.add_shutdown_callback / ctx.addShutdownCallback을 등록하여 ElevenLabs WebSocket을 정상적으로 닫으세요. 기본적으로 마지막 비에이전트 참여자가 나가면 룸과 작업이 종료됩니다.

다음 단계