AIボットと電話を繋ぐ - Twilio Voice - ConversationRelay

「コールセンターでAIを利用したい」「代表電話の応対をAIに任せたい」など電話応対をAIに行わせたいという要望を多く聞きます。今回のブログでは、Twilioの新機能「[Conversation Relay](https://www.twilio.com/docs/voice/twiml/connect/conversationrelay)」を使って、AIと低遅延で通話できる仕組みをご紹介します。

今回は、私がGitHubリポジトリにアップしている[ConversationRelayDemo]() を中心に、セットアップや実際のコードを詳しく解説していきます。

## Conversation Relayとは？

「Conversation Relay」は、Twilio Voiceの通話をTwilioの中継サーバーを通すことで、音声をリアルタイムに処理したり、AIと連携できる機能です。

たとえばこんなことができます：

- 音声通話をリアルタイムで文字起こし＆翻訳  
- AIを使って話し相手の感情を分析  
- 会話内容を録音＆オン・オフ制御  
- 自前のLLM（大規模言語モデル）を組み込んで応答を生成  
- 1秒未満のレイテンシで自然なやりとりを実現！

## 技術構成
Conversation Relayは主に以下の3つで構成されます：

| 機能                  | 説明                        |
| ------------------- | ------------------------- |
| Speech to Text（STT） | 話者の音声をテキストに変換             |
| Text to Speech（TTS） | AIの返答テキストを自然な音声に変換        |
| Orchestration       | WebSocket経由で低遅延な応答制御・割り込み |

### 構成イメージ
![ConversationRelayイメージ図 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649595/92a4f21f-ba34-4b2d-9645-e9423177b1fd.png)



## デモ構成とコード

ご紹介するコードは下記になります。
セットアップ後、電話をかけるとOpenAIと接続して自由な会話が楽しめます。
https://github.com/MitsuharuNakamura/conversationRelayDemo

### 必要なもの

- Node.js v14以上
- OpenAIのAPIキー
- Ngrok（有料プランでサブドメイン固定できると便利）
- Twilioアカウント（電話番号付き）

### セットアップ手順

```bash
git clone https://github.com/MitsuharuNakamura/conversationRelayDemo.git
cd conversationRelayDemo
npm install
```

`.env` ファイルを作って環境変数をセット：

```env
PORT=9999
OPENAI_API_KEY=sk-xxxx
SYSTEM_PROMPT=あなたは、コールセンターのオペレーターです。
```

### TwilioのTwiML設定

TwiML Binに以下を設定：

```xml
<Response>
  <Connect>
    <ConversationRelay 
      url="wss://<your_subdomain>.jp.ngrok.io"
      language="ja-JP"
      welcomeGreeting="もしもし。こちらは旅行アシスタントです。ご要件をお話ください。"
      ttsProvider="google"
      voice="ja-JP-Standard-B" />
  </Connect>
</Response>
```

電話番号の「A call comes in」でこのTwiML Binを選択して保存。

## 使い方

1. サーバー起動：`node server.js`
2. Ngrokで公開：`ngrok http 9999 --subdomain=xxxx`
3. TwiML Binの「url="wss://<your_subdomain>.jp.ngrok.io"」をサブドメインを修正
4. Twilioの番号に発信
5. AIが日本語で応答してくれれば成功

※通話終了時は自分の端末側で切るのをお忘れなく

## Websocketサーバのコード解説

### 必要なモジュールのインポートと初期設定

```javascript
const WebSocket = require('ws');
const dotenv = require('dotenv');
const { Configuration, OpenAIApi } = require('openai');

dotenv.config();

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);
```

WebSocketサーバー、環境変数、OpenAIのAPIクライアントを初期化しています。

### WebSocketサーバーの作成

```javascript
const wss = new WebSocket.Server({ port: process.env.PORT || 9999 });
```

指定したポートでサーバーを起動します。


### WebSocketサーバが受信するメッセージ
Twilioからのメッセージを受け取り、適切に振り分けて処理します。

```js
ws.on('message', async (message) => {
    console.log(`Received: ${message}`);

    try {
        const parsedMessage = JSON.parse(message);

        if(parsedMessage.type === 'prompt'){
            const question = parsedMessage.voicePrompt;
            // AIに問い合わせ処理...
        } else {
            console.log("voicePromptプロパティは存在しません。");
        }
    } catch (error) {
        console.error('メッセージのパースに失敗しました:', error);
    }
});
```
- `prompt` 以外（例：`setup`, `interrupt`）はログ出力のみにしています。
- `voicePrompt` があればそれをAIに送信します。

###  WebSocketサーバから送信するメッセージ
OpenAI APIから得られたストリーミング応答を、適切に一文ごとに区切ってクライアントに返します。

```js
for await (const chunk of stream) {
    const message = chunk.choices[0]?.delta?.content;
    if (message) {
        buffer += message;
        let sentences = buffer.split(sentenceDelimiterRegex);
        buffer = sentences.pop();
        for (const sentence of sentences) {
            console.log("DEBUG: " + sentence);
            ws.send(JSON.stringify({ type: 'text', token: sentence, last: true }));
        }
    }
}

if (buffer) {
    console.log("DEBUG: " + buffer);
    ws.send(JSON.stringify({ type: 'text', token: buffer, last: true }));
}
```
- OpenAIの応答を逐次受け取り、文単位で分割して `type: text` として送信
- `last: true` を付けることでTTS側の制御を簡素化
- 文の判定には日本語用の正規表現を使用

## 補足：日本語文章の分割に使っている正規表現について

```javascript
const sentenceDelimiterRegex = /(?<=[。、？])/; // 日本語の文末記号で分割
// const sentenceDelimiterRegex = /(?<=[.!?])\s+/; // 英語用（参考）
```

`(?<=...)` は肯定の後読みで、句点や読点、疑問符の直後で文を分割しています。

```javascript
"こんにちは。今日は天気がいいですね？明日も晴れるといいな。".split(sentenceDelimiterRegex)
// => ["こんにちは。", "今日は天気がいいですね？", "明日も晴れるといいな。"]
```

文章を分割することで、AIの応答がより自然になります。

## ConversationRelayとのインターフェース
### ConversationRelay → server.jsに送られるメッセージ

| 種別 (`type`) | 説明             | 主なフィールド                                                  | 送信タイミング          |
| ----------- | -------------- | -------------------------------------------------------- | ---------------- |
| `setup`     | セッション初期情報の通知   | `sessionId`, `callSid`, `from`, `to`, `customParameters` | WebSocket接続直後    |
| `prompt`    | 発話検出・STT結果の通知  | `voicePrompt`, `lang`, `last`                            | 音声認識された直後        |
| `dtmf`      | キー入力検出（DTMF）   | `digit`                                                  | DTMF検出時（DTMF有効時） |
| `interrupt` | 話者がTTSを割り込んだ通知 | `utteranceUntilInterrupt`, `durationUntilInterruptMs`    | 話し始めによる割り込み      |
| `error`     | エラー通知          | `description`                                            | セッション中にエラー発生時    |

---

## server.js → ConversationRelay に送るメッセージ

| 種別 (`type`)  | 説明           | 主なフィールド                                | 使用タイミング         |
| ------------ | ------------ | -------------------------------------- | --------------- |
| `text`       | テキストをTTSで再生  | `token`, `last`                        | AI応答や案内を再生したいとき |
| `play`       | メディアファイル再生指示 | `source`, `loop`, `preemptible`        | 音声ファイルの再生       |
| `sendDigits` | DTMF信号送出     | `digits`                               | IVRやDTMF操作時     |
| `language`   | 言語設定の変更      | `ttsLanguage`, `transcriptionLanguage` | 言語を切り替えたいとき     |
| `end`        | セッション終了の指示   | `handoffData`（任意）                      | 通話の制御をTwilioへ戻す |
|              |              |                                        |                 |

## まとめ

Conversation Relayを使えば、Twilioで以下が実現できます：

- AIによる自然な会話（1秒未満のレイテンシ）
- 音声のリアルタイム処理・記録・監視
- 自前のAIモデルを統合するBYO-LLM対応

ぜひ、自分のプロジェクトでも試してみてください。