> This is a page from the ElevenLabs documentation. For a complete page index, fetch https://elevenlabs.io/docs/llms.txt. For the full documentation in a single file, fetch https://elevenlabs.io/docs/llms-full.txt.

# LiveKit連携

このガイドでは、ElevenLabs Speech EngineをLiveKitルームの音声レイヤーとして使用する方法を紹介します。LiveKit Agentsワーカーは参加者としてルームに参加し、ユーザーのオーディオトラックを購読してSpeech EngineへのWebSocketを開き、Speech Engineが合成したオーディオを独自のトラックとしてルームに公開します。

## アーキテクチャ

Speech Engineは2種類のWebSocket接続を受け付けます。

* ElevenLabs APIが接続する**ブレインWebSocket**。サーバーでSpeech Engine SDK（`engine.serve()`／`engine.attach()`）を実行し、応答するための文字起こしを受け取ります。
* クライアントが接続する**会話WebSocket**。ブラウザはWebRTCトークン経由で接続します。LiveKit Agentsワーカーなどの非ブラウザクライアントは、署名付きURL経由で接続し、未加工のPCMオーディオを双方向にストリーミングします。

LiveKitワーカーは2つ目の接続を使用します。LiveKitルーム内の参加者に代わって、Speech Engineの「クライアント」として動作します。

```mermaid
sequenceDiagram
    participant Browser
    participant LK as LiveKit Room
    participant Worker as Agents Worker
    participant EL as ElevenLabs (conversation WS)
    participant Brain as Brain Server

    Browser->>LK: Join room (LiveKit token)
    Worker->>LK: Join room (dispatched)
    Worker->>EL: Open conversation WebSocket (signed URL)

    loop Conversation
        Browser->>LK: Microphone audio (Opus)
        LK->>Worker: Decoded PCM frames
        Worker->>EL: user_audio_chunk (base64 PCM)
        EL->>Brain: user_transcript
        Brain-->>EL: agent_response (streamed)
        EL->>Worker: audio (base64 PCM)
        Worker->>LK: Publish PCM frames
        LK->>Browser: Audio (Opus)
    end
```

ブレインサーバーは[Speech Engineクイックスタート](/docs/ja/eleven-api/guides/cookbooks/speech-engine)から変更不要です。LiveKitワーカーがブラウザの代わりにオーディオソースとなりますが、LLMロジックはそのままです。

## このパターンを使う場面

ルーム自体が体験の一部である場合は、LiveKitブリッジを使用します。

* 複数のユーザーが同時にエージェントと会話するマルチ参加者セッション
* トランスポートを切り替えるとクライアントが機能しなくなる、既存のLiveKitデプロイメント
* 画面共有、ビデオ、テキストチャットとルームを共有する音声エージェント
* 通話中にAIエージェントが必要な、SIPからLiveKitへディスパッチされる通話

他の参加者なしでブラウザからSpeech Engineへの音声ループだけが必要な場合は、[Speech Engineクイックスタート](/docs/ja/eleven-api/guides/cookbooks/speech-engine#client-setup)のWebRTCクライアントのほうがシンプルです。Speech Engineがブラウザと直接WebRTCで通信するため、LiveKitルームは不要です。

## 前提条件

* LiveKitプロジェクト（[LiveKit Cloud](https://cloud.livekit.io/)またはセルフホストサーバー）。ワーカーには`LIVEKIT_URL`、`LIVEKIT_API_KEY`、`LIVEKIT_API_SECRET`が必要です。
* ElevenLabs Speech Engine。[Speech Engineクイックスタート](/docs/ja/eleven-api/guides/cookbooks/speech-engine)に従って作成し、ブレインサーバーを実行してください。
* Python 3.9+またはNode.js 18+。

> **Note**
>
> Nodeブリッジワーカーは
> [`@livekit/rtc-node`](https://www.npmjs.com/package/@livekit/rtc-node)を使用します。これは現在
> Developer Previewです。本番環境へのデプロイには、Pythonワーカーを推奨します。

## Speech Engineのオーディオ形式を設定する

LiveKitの`AudioStream`は、受信するOpusトラックを指定したPCMサンプルレートにリサンプリングします。そのため、Speech Engineの入力に直接合わせられます。Speech Engineを更新し、ASR入力には16 kHz PCMを受け入れ、TTS出力には24 kHz PCMを出力するようにします。

**`configure_engine.py`**

```python title="configure_engine.py"
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())
```

**`configure-engine.mts`**

```typescript title="configure-engine.mts"
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import "dotenv/config";

const elevenlabs = new ElevenLabsClient({
  apiKey: process.env.ELEVENLABS_API_KEY,
});

await elevenlabs.speechEngine.update("seng_8k3m9xr4hjnfg983brhmhkd98n6", {
  asr: { userInputAudioFormat: "pcm_16000" },
  tts: { agentOutputAudioFormat: "pcm_24000" },
});
```

Speech EngineのPCMは、全体を通して符号付き16ビット・リトルエンディアンです。その他の対応レートについては、[オーディオ形式リファレンス](#audio-format-reference)を参照してください。

## ブリッジワーカーを構築する

ワーカーは、LiveKitサーバーに接続してジョブを待機し、割り当てられたルームに参加して、ルームとSpeech Engineの間でオーディオをブリッジする長時間実行プロセスです。

#### 依存関係をインストールする

**`Python`**

```bash title="Python"
pip install "livekit-agents" "livekit-api" "elevenlabs" "aiohttp" "python-dotenv"
```

**`Node`**

```bash title="Node"
npm install @livekit/agents @livekit/rtc-node @elevenlabs/elevenlabs-js ws dotenv
```

#### Speech Engineの署名付きURLを生成する

ワーカーはSpeech Engine会話WebSocket用の短期間有効な署名付きURLをリクエストします。署名付きURLにはエンジンIDと1回限りの署名が含まれるため、APIキーを公開せずにWebSocketを開けます。

**`bridge.py`**

```python title="bridge.py"
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
```

**`bridge.mts`**

```typescript title="bridge.mts"
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";

const elevenlabs = new ElevenLabsClient({
  apiKey: process.env.ELEVENLABS_API_KEY,
});

async function signedUrl(): Promise<string> {
  const response = await elevenlabs.conversationalAi.conversations.getSignedUrl({
    agentId: process.env.SPEECH_ENGINE_ID!,
  });
  return response.signedUrl;
}
```

#### ワーカーのエントリーポイントを定義する

ワーカーがルームにディスパッチされるたびに、そのエントリーポイントが実行されます。エントリーポイントはルームに接続し、Speech Engine会話WebSocketを開き、2つのオーディオブリッジを開始します。1つはSpeech Engineに送る発信者オーディオ用、もう1つは戻ってくる合成オーディオ用です。

**`bridge.py`**

```python title="bridge.py" maxLines=0
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",
    ))
```

**`bridge.mts`**

```typescript title="bridge.mts" maxLines=0
import {
  type JobContext,
  WorkerOptions,
  cli,
  defineAgent,
} from "@livekit/agents";
import {
  AudioFrame,
  AudioSource,
  AudioStream,
  LocalAudioTrack,
  RoomEvent,
  TrackKind,
  TrackPublishOptions,
  TrackSource,
} from "@livekit/rtc-node";
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import WebSocket from "ws";
import { fileURLToPath } from "node:url";
import "dotenv/config";

const elevenlabs = new ElevenLabsClient({
  apiKey: process.env.ELEVENLABS_API_KEY,
});
const SPEECH_ENGINE_ID = process.env.SPEECH_ENGINE_ID!;

const USER_INPUT_RATE = 16000;
const AGENT_OUTPUT_RATE = 24000;

async function signedUrl(): Promise<string> {
  const response = await elevenlabs.conversationalAi.conversations.getSignedUrl({
    agentId: SPEECH_ENGINE_ID,
  });
  return response.signedUrl;
}

export default defineAgent({
  entry: async (ctx: JobContext) => {
    let resolveElReady: (ws: WebSocket) => void;
    const elReady = new Promise<WebSocket>((resolve) => {
      resolveElReady = resolve;
    });

    // Register the subscriber BEFORE ctx.connect() so we don't miss
    // tracks that get auto-subscribed during the connection handshake.
    ctx.room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
      if (track.kind !== TrackKind.KIND_AUDIO) return;
      if (participant.identity === ctx.room.localParticipant?.identity) return;

      (async () => {
        const ws = await elReady;
        const stream = new AudioStream(track, {
          sampleRate: USER_INPUT_RATE,
          numChannels: 1,
        });
        for await (const frame of stream) {
          const payload = Buffer.from(
            frame.data.buffer,
            frame.data.byteOffset,
            frame.data.byteLength,
          ).toString("base64");
          ws.send(JSON.stringify({ user_audio_chunk: payload }));
        }
      })();
    });

    await ctx.connect();

    const source = new AudioSource(AGENT_OUTPUT_RATE, 1);
    const track = LocalAudioTrack.createAudioTrack("elevenlabs-agent", source);
    const publishOptions = new TrackPublishOptions();
    publishOptions.source = TrackSource.SOURCE_MICROPHONE;
    await ctx.room.localParticipant!.publishTrack(track, publishOptions);

    const ws = new WebSocket(await signedUrl());
    await new Promise<void>((resolve, reject) => {
      ws.once("open", () => resolve());
      ws.once("error", reject);
    });
    ws.send(JSON.stringify({ type: "conversation_initiation_client_data" }));
    resolveElReady!(ws);

    // Serialize captureFrame calls — concurrent captures throw
    // InvalidState in the rtc-node native layer.
    let captureChain: Promise<unknown> = Promise.resolve();

    ws.on("message", (raw) => {
      const event = JSON.parse(raw.toString());
      if (event.type === "audio") {
        const pcm = Buffer.from(event.audio_event.audio_base_64, "base64");
        const samples = new Int16Array(
          pcm.buffer, pcm.byteOffset, pcm.byteLength / 2,
        );
        const frame = new AudioFrame(
          samples, AGENT_OUTPUT_RATE, 1, samples.length,
        );
        captureChain = captureChain
          .then(() => source.captureFrame(frame))
          .catch((err) => console.warn("captureFrame:", err.message));
      } else if (event.type === "interruption") {
        source.clearQueue();
      } else if (event.type === "ping") {
        ws.send(JSON.stringify({
          type: "pong", event_id: event.ping_event?.event_id,
        }));
      }
    });

    ctx.addShutdownCallback(async () => {
      ws.close();
    });
  },
});

cli.runApp(new WorkerOptions({
  agent: fileURLToPath(import.meta.url),
  agentName: "elevenlabs-bridge",
}));
```

ワーカーは、`track_subscribed`ハンドラーでローカル参加者のIDと比較することで、自身が公開したオーディオを除外します。このチェックがない場合、ワーカーは自身が合成したオーディオをSpeech Engineへ送り返そうとしてしまいます。

正しく動作させるためには、順序に関する2つの重要なポイントがあります。

* **リスナーのタイミング**：`TrackSubscribed`は`ctx.connect()`の前に登録します。LiveKitは接続ハンドシェイク中に既存トラックを自動購読するため、その後に登録したリスナーはイベントを見逃す可能性があります。オーディオポンプはSpeech Engine WebSocketの`Future`／`Promise`を待機するため、すぐに購読でき、接続が開いた直後からオーディオを転送できます。
* **TypeScriptのみ：キャプチャの直列化**：`@livekit/rtc-node`の`AudioSource.captureFrame`は、同時に呼び出すと`InvalidState`をスローします。TypeScriptハンドラーでは、Promiseチェーンを使ってキャプチャを直列化します。Pythonの単一の`async for el_to_room`ループは自然に順次実行されるため、これは不要です。

#### ワーカーを起動する

**`Python`**

```bash title="Python"
python bridge.py dev
```

**`Node`**

```bash title="Node"
npx tsx bridge.mts dev
```

`dev`ではホットリロードとカラー表示のログが有効になります。本番環境では、JSONログとグレースフルシャットダウンのために`start`を使用してください。

ワーカーはLiveKitサーバーに接続し、ジョブの割り当てを待機します。ディスパッチされるまで、どのルームにも参加しません。

## ワーカーをルームにディスパッチする

ワーカーには`agent_name`があるため、明示的なディスパッチを使用します。バックエンドから指示された場合にのみルームに参加します。最も簡単なパターンは、ブラウザが接続に使用するLiveKitアクセストークンに`RoomAgentDispatch`を含めることです。

**`token_server.py`**

```python title="token_server.py"
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)

```

**`token-server.mts`**

```typescript title="token-server.mts"
import express from "express";
import { AccessToken } from "livekit-server-sdk";
import "dotenv/config";

const app = express();

app.get("/api/livekit-token", async (req, res) => {
  const room = (req.query.room as string) ?? "demo-room";
  const identity = (req.query.identity as string) ?? "web-user";

  const token = new AccessToken(
    process.env.LIVEKIT_API_KEY!,
    process.env.LIVEKIT_API_SECRET!,
    { identity },
  );
  token.addGrant({ roomJoin: true, room });
  token.roomConfig = {
    agents: [{ agentName: "elevenlabs-bridge" }],
  };

  res.json({
    token: await token.toJwt(),
    url: process.env.LIVEKIT_URL,
  });
});

app.listen(3002, () => {
  console.log("Token server listening on port 3002");
});
```

ブラウザがこのトークンを使用してルームを作成または参加すると、LiveKitは同じルームにブリッジワーカーを自動的にディスパッチします。

## ブラウザから接続する

ブラウザに必要なのは標準のLiveKitクライアントのみです。Speech Engineとは直接やり取りしません。

**`App.tsx`**

```typescript title="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_8000`  | 8 kHz     | 符号付き16ビットLE PCM | ASR入力のみ。                                  |
| `pcm_16000` | 16 kHz    | 符号付き16ビットLE PCM | LiveKitのユーザー入力に推奨。                        |
| `pcm_22050` | 22.05 kHz | 符号付き16ビットLE PCM |                                           |
| `pcm_24000` | 24 kHz    | 符号付き16ビットLE PCM | LiveKitのエージェント出力に推奨。                      |
| `pcm_44100` | 44.1 kHz  | 符号付き16ビットLE PCM | TTS出力にはIndependent Publisher tier以上が必要です。 |
| `pcm_48000` | 48 kHz    | 符号付き16ビットLE PCM | ASR入力のみ。                                  |
| `ulaw_8000` | 8 kHz     | μ-law           | Twilio Media Streamsで使用されます。              |

LiveKitの`AudioStream`と`AudioSource`はリサンプリングを自動的に処理します。`AudioStream`には任意のサンプルレートをリクエストでき、SDKが基盤となる48 kHz Opusトラックから変換します。

## 本番運用時の考慮事項

* **明示的なディスパッチ**：`WorkerOptions`では必ず`agent_name`／`agentName`を設定してください。自動ディスパッチでは、LiveKitプロジェクトで作成されるすべてのルームに対してワーカーが起動します。これは通常、意図した動作ではありません。
* **ブレインサーバーの認証**：Speech Engineに共有シークレットを設定し、ブレインサーバーで検証してください。これにより、Speech Engineだけがエンドポイントにアクセスできるようになります：
  ```python
  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を適切に閉じてください。デフォルトでは、最後の非エージェント参加者が退出すると、ルーム（およびジョブ）は終了します。

## 次のステップ

#### [Speech Engineクイックスタート](/docs/ja/eleven-api/guides/cookbooks/speech-engine)

トランスクリプトに応答するブレインサーバーを構築します。

#### [Pipecatインテグレーション](/docs/ja/eleven-api/guides/how-to/speech-engine/pipecat-integration)

Speech Engineの背後にあるLLMパイプラインとしてPipecatを使用します。

#### [Python SDKリファレンス](/docs/ja/eleven-api/resources/libraries/speech-engine/python-sdk-reference)

Speech Engine Python SDKのクラス、メソッド、イベント。

#### [JavaScript SDKリファレンス](/docs/ja/eleven-api/resources/libraries/speech-engine/javascript-sdk-reference)

Speech Engine JavaScript SDKのクラス、メソッド、イベント。