Twilio Voice、OpenAIのRealtime API、Node.jsを使用してAI音声アシスタントを構築

August 28, 2025
執筆者
Paul Kamp
Twilion
Dominik Kundel
寄稿者
Twilio の寄稿者によって表明された意見は彼ら自身のものです
レビュー担当者

Twilio Voice、OpenAIのRealtime API、Node.jsを使用してAI音声アシスタントを構築

OpenAIからRealtime APIがリリースされました。このAPIは、GPT Realtimeマルチモーダルモデルに音声変換(S2S)機能を提供します。音声を直接入力・出力できるため、従来のように音声認識(STT)でテキストに変換してからテキスト読み上げ(TTS)により音声に戻すといった処理は不要です。

その結果、S2Sモデルはレイテンシーを低減し、OpenAIのRealtime APIはまるで人間同士の会話のような自然なやり取りを実現します。実際にお試しいただくと、その違いが分かるはずです。そのため、OpenAIとのコラボレーションによって実現したこの統合機能を、今回のリリースに合わせて皆さまに提供できることを大変うれしく思います。

このチュートリアルでは、Node.jsを使用し、Twilio VoiceOpenAI Realtime APIでAI音声アシスタントを構築する方法を解説します。ツールの構築後は、人間と同じようにAIアシスタントと会話し、事実関係の質問はもちろん、ジョークをお願いするといったやり取りも行えるようになります。一緒にTwilio Media Streamサーバーを設定しましょう。通話の音声を受信し、それをOpenAI Realtime APIで処理した後、AIの応答をTwilioに返すことで会話を継続できます。

準備が整ったら、さぁ、始めましょう!

このアプリは、Code Exchangeで既製のアプリケーションとしても入手可能です。こちらで確認できます。

ここでは、Node.jsを使用してVoice AI Assistantへ実際に発信する方法を紹介します。

前提条件

このチュートリアルを進めるには、以下が必要です。

  • Node.js 18+(このチュートリアルではバージョン18.20.4を使用。ダウンロードはこちら
  • Twilioアカウント。お持ちでない場合は、こちらから無料トライアルに登録できます。
  • Voice機能を備えたTwilio番号。電話番号の購入手順はこちら
  • OpenAIアカウントとOpenAI APIキー。こちらからサインアップできます。
  • OpenAI Realtime APIアクセス。詳細はこちらをご覧ください
  • (オプション)ngrokなどのトンネリングソリューションを使用してローカルサーバーをインターネットに公開し、テストを行います。ngrokはこちらからダウンロードしてください。
  • 発信通話が可能な携帯電話または固定電話

これで準備は完了です。構築を始めましょう。

Realtime API speech-to-speech Nodeプロジェクトの設定

以下のステップでは、プロジェクトの設定、依存関係のインストール、TwilioとOpenAI間のWebSocket接続中継に必要なコードの作成について説明します。

こちらでTwilioのレポジトリもご覧いただけます。このチュートリアルのビデオバージョンも以下にご用意しています。

では、ここから本格的に始めていきましょう。

ステップ1: プロジェクトの初期化

まず、新しいNode.jsプロジェクトを設定します。

mkdir speech-assistant-openai-realtime-api-node
cd speech-assistant-openai-realtime-api-node
npm init -y; npm pkg set type="module";

ステップ2: 依存関係のインストール

次に、プロジェクトに必要な依存関係をインストールします。Webフレームワーク「Fastify」を使用し、WebSocketのサポートも必要になります。機密性の高い環境変数は.envファイルに保存します。

npm install fastify ws dotenv @fastify/formbody @fastify/websocket

ステップ3: プロジェクトファイルの作成

メインコード用にindex.jsという名前のファイルを作成します。環境変数を保存するための.envファイルも用意します。この手法の詳細については、こちらをご覧ください。

この例では、.envにはOpenAIのAPIキーのみが必要です。このキーにRealtime APIへのアクセス権限があることを確認してください。

ステップ3.1: .envファイルの作成

まず、.envファイルを作成します。

touch .env

次に、任意のテキストエディタで、1行目にOPENAI_API_KEYを追加します。

OPENAI_API_KEY="your_openai_api_key_here"

ステップ3.2: index.jsファイルの作成

次に、プロジェクトディレクトリに新しいファイルindex.jsを作成します。

touch index.js

ステップ4: サーバーコードの構築

ここまで順調に進めてきました。ここからはチュートリアルの内容に入ります。index.jsのコードを複数のステップに分け、それぞれのパートを説明していきます。ここを飛ばして先に進みたい場合はCode Exchangeアプリリポジトリに切り替えても構いません。

ステップ4.1: 依存関係のインポート、環境変数の読み込み、Fastifyの初期化

ここはほぼ通常通りです。必要なモジュールをインポートしてからパス解決を設定し、.envファイルから環境変数を読み込みます。

以下のコードをindex.jsに貼り付けてください。

import Fastify from 'fastify';
import WebSocket from 'ws';
import fs from 'fs';
import dotenv from 'dotenv';
import fastifyFormBody from '@fastify/formbody';
import fastifyWs from '@fastify/websocket';
// Load environment variables from .env file
dotenv.config();
// Retrieve the OpenAI API key from environment variables. You must have OpenAI Realtime API access.
const { OPENAI_API_KEY } = process.env;
if (!OPENAI_API_KEY) {
    console.error('Missing OpenAI API key. Please set it in the .env file.');
    process.exit(1);
}
// Initialize Fastify
const fastify = Fastify();
fastify.register(fastifyFormBody);
fastify.register(fastifyWs);

ステップ4.2: 定数の定義

次に、システムメッセージ、音声、サーバーポートの定数を定義します。また、コンソールにログ出力するOpenAIのイベントもここで選択します。

以下のコードをファイルに貼り付けてください。

// Constants
const SYSTEM_MESSAGE = 'You are a helpful and bubbly AI assistant who loves to chat about anything the user is interested about and is prepared to offer them facts. You have a penchant for dad jokes, owl jokes, and rickrolling – subtly. Always stay positive, but work in a joke when appropriate.';
const VOICE = 'alloy';
const TEMPERATURE = 0.8; // Controls the randomness of the AI's responses
const PORT = process.env.PORT || 5050; // Allow dynamic port assignment
// List of Event Types to log to the console. See the OpenAI Realtime API Documentation: https://platform.openai.com/docs/api-reference/realtime
const LOG_EVENT_TYPES = [
    'error',
    'response.content.done',
    'rate_limits.updated',
    'response.done',
    'input_audio_buffer.committed',
    'input_audio_buffer.speech_stopped',
    'input_audio_buffer.speech_started',
    'session.created',
    'session.updated'
];

ここで、SYSTEM_MESSAGEはAIの会話のトーンや振る舞いを設定します。この内容が、最終的にinstructionsとしてOpenAIに渡されます。このメッセージをカスタマイズして、AIの性格や対応スタイルなどを指定できます。もう少し先のセクションで、会話に影響を与えるこのメッセージをOpenAIに渡す方法を説明します。プロンプトの詳細については、OpenAIのRealtime Prompting Guideを参照してください。

VOICE定数はAIの音声を指定します。こちらから音声を選択できます。

TEMPERATUREは、LLMの応答のランダム性を制御します(値が高いほどランダム性が高くなります)。

PORT定数は、アプリケーションが開くポートを指定します。これについては、後ほどngrokセクションで詳しく説明します。

最後のLOG_EVENT_TYPESは、OpenAIのイベントのうち、コマンドラインに表示するイベントタイプを指定します。全イベント一覧は、OpenAIのRealtime APIドキュメントで確認できます。

ステップ4.3: 2つのルートの定義

さて、ここからがコードの核心部分です。最上位のルート(主にヘルスチェック用)と、着信を処理するルートを定義します。この/incoming-callルートは、TwiML(Twilio Markup Language)を返し、通話の処理方法をTwilioに指示します。詳細は後ほど説明します。

LOG_EVENT_TYPESの定数リストを設定したら、以下のコードをindex.jsに貼り付けます。

// Root Route
fastify.get('/', async (request, reply) => {
    reply.send({ message: 'Twilio Media Stream Server is running!' });
});
// Route for Twilio to handle incoming and outgoing calls
// <Say> punctuation to improve text-to-speech translation
fastify.all('/incoming-call', async (request, reply) => {
    const twimlResponse = `<?xml version="1.0" encoding="UTF-8"?>
                          <Response>
                              <Say voice="Google.en-US-Chirp3-HD-Aoede">Please wait while we connect your call to the A. I. voice assistant, powered by Twilio and the Open A I Realtime API</Say>
                              <Pause length="1"/>
                              <Say voice="Google.en-US-Chirp3-HD-Aoede">O.K. you can start talking!</Say>
                              <Connect>
                                  <Stream url="wss://${request.headers.host}/media-stream" />
                              </Connect>
                          </Response>`;

    reply.type('text/xml').send(twimlResponse);
});

他のTwiMLと同様に、まずXMLバージョンから開始し、<Response>タグを開きます。次に、Twilioに発信者への短いメッセージ(自由に決めてください)を言わせ、2秒間待ってから、発信者に話し始めるよう促します。

動詞<Connect>名詞<Stream>を組み合わせ、TwilioのMedia Streamsを使用して双方向ストリームを開きます。ここがデモの真骨頂です。次のステップでは、2つのWebSocket間で音声を中継する方法を紹介します。

ステップ4.4: WebSocket接続の処理

ここで、メディアストリーミング用のWebSocketルート(前のセクションでTwilioに対して定義したルート)を設定し、TwilioとOpenAIの両方に対してWebSocket接続を確立する必要があります。少し長いコードですが、コードブロックの後で詳しく説明します。

以下のコードをルート定義の下に貼り付けてください。

// WebSocket route for media-stream
fastify.register(async (fastify) => {
    fastify.get('/media-stream', { websocket: true }, (connection, req) => {
        console.log('Client connected');
        const openAiWs = new WebSocket(`wss://api.openai.com/v1/realtime?model=gpt-realtime&temperature=${TEMPERATURE}`, {
            headers: {
                Authorization: `Bearer ${OPENAI_API_KEY}`,
            }
        });
        let streamSid = null;

        const sendSessionUpdate = () => {
            const sessionUpdate = {
                type: 'session.update',
                session: {
                    type: 'realtime',
                    model: "gpt-realtime",
                    output_modalities: ["audio"],
                    audio: {
                        input: { format: { type: 'audio/pcmu' }, turn_detection: { type: "server_vad" } },
                        output: { format: { type: 'audio/pcmu' }, voice: VOICE },
                    },
                    instructions: SYSTEM_MESSAGE,
                },
            };
            console.log('Sending session update:', JSON.stringify(sessionUpdate));
            openAiWs.send(JSON.stringify(sessionUpdate));
        };

        // Open event for OpenAI WebSocket
        openAiWs.on('open', () => {
            console.log('Connected to the OpenAI Realtime API');
            setTimeout(sendSessionUpdate, 250); // Ensure connection stability, send after .25 seconds
        });
        // Listen for messages from the OpenAI WebSocket (and send to Twilio if necessary)
        openAiWs.on('message', (data) => {
            try {
                const response = JSON.parse(data);
                if (LOG_EVENT_TYPES.includes(response.type)) {
                    console.log(`Received event: ${response.type}`, response);
                }
                if (response.type === 'session.updated') {
                    console.log('Session updated successfully:', response);
                }
                if (response.type === 'response.output_audio.delta' && response.delta) {
                    const audioDelta = {
                        event: 'media',
                        streamSid: streamSid,
                        media: { payload: Buffer.from(response.delta, 'base64').toString('base64') }
                    };
                    connection.send(JSON.stringify(audioDelta));
                }
            } catch (error) {
                console.error('Error processing OpenAI message:', error, 'Raw message:', data);
            }
        });
        // Handle incoming messages from Twilio
        connection.on('message', (message) => {
            try {
                const data = JSON.parse(message);
                switch (data.event) {
                    case 'media':
                        if (openAiWs.readyState === WebSocket.OPEN) {
                            const audioAppend = {
                                type: 'input_audio_buffer.append',
                                audio: data.media.payload
                            };
                            openAiWs.send(JSON.stringify(audioAppend));
                        }
                        break;
                    case 'start':
                        streamSid = data.start.streamSid;
                        console.log('Incoming stream has started', streamSid);
                        break;
                    default:
                        console.log('Received non-media event:', data.event);
                        break;
                }
            } catch (error) {
                console.error('Error parsing message:', error, 'Message:', message);
            }
        });
        // Handle connection close
        connection.on('close', () => {
            if (openAiWs.readyState === WebSocket.OPEN) openAiWs.close();
            console.log('Client disconnected.');
        });
        // Handle WebSocket close and errors
        openAiWs.on('close', () => {
            console.log('Disconnected from the OpenAI Realtime API');
        });
        openAiWs.on('error', (error) => {
            console.error('Error in the OpenAI WebSocket:', error);
        });
    });
});

上記のとおり、まずWebSocketのルート(/media-stream)を設定し、TwilioとOpenAI間のメディアストリーミングを処理できるようにします。これは、先ほどTwiMLの説明の中で参照したルートです。続く2つの領域については、もう少し詳しい説明が必要です。

OpenAI Realtime APIのセッションと会話の設定

次に、OpenAIとのセッションを設定します。この設定は、接続が開いた後、少し遅れてJSONオブジェクトとしてOpenAIのWebSocketに送信されます。

続いて、AIとのやり取りやAIの応答をsendSessionUpdate()関数で定義します。ここで選んだオプションの詳細は、OpenAIのRealtime APIドキュメントで確認できます。

sendSessionUpdate関数でOpenAIセッションの属性も設定します。

  • type: ここでrealtimeを使用していることをOpenAIに伝えます
  • model: spt-realtimeモデルを使用しています
  • audio.input.turn_detection: server_vadでサーバー側の音声活動検出(VAD)を有効にします。
  • audio.input.format.type / audio.output.format.type: オーディオ形式を指定します。ここではTwilioの要件に合わせてaudio/pcmuに変更しています。
  • audio.output.voice: 先ほど設定したVOICEモデル。
  • instructions: SYSTEM_MESSAGEを使用してAIの対応に影響を与えます。
  • output_modalities: 音声コミュニケーションを有効にします。
TwilioとOpenAIのWebSocket間の中継

以下の行では、Twilio Media StreamとOpenAI Realtime AIのWebSocket接続間で、TwilioがサポートするG.711 u-law形式の音声データを中継します。通話が開始されると、ここで発信者の音声が処理され、AIが生成した音声ストリームが返されます。

OpenAI RealtimeとTwilio間で中継する方法について、詳しい手順を以下に説明します。

  • startイベント: ストリーム固有のID(streamSid)を取得します。
  • mediaイベント: mediaイベント: 通話中に送られてくる音声データ(ペイロード)を処理し、OpenAIに転送します。
  • response.output_audio.delta: OpenAIからの音声データを処理し、再エンコードしてTwilioに送信します。
  • Twilio WebSocket closeイベント: クライアントの切断を処理し、ストリームを閉じます。

ステップ4.5: サーバーの準備

最後に、Fastifyサーバーを起動して仕上げます(渡された、または定義されたポートを使用)。既存のコードの下に以下を貼り付ければ準備完了です!

fastify.listen({ port: PORT }, (err) => {
    if (err) {
        console.error(err);
        process.exit(1);
    }
    console.log(`Server is listening on port ${PORT}`);
});

サーバーの実行

ファイルを閉じて、元の画面に戻ります。

次のコマンドでサーバーを起動できるようになりました。

node index.js

サーバーが正常に起動すると、ターミナルに[Server is listening on port 5050](サーバーがポート5050で待ち受けています)と表示されます(ポート番号を指定した場合はその番号が表示されます)。

セットアップの仕上げ

では、Twilioに指示を与えて配線を完了させましょう。リバースプロキシのngrokを使用してサーバーを公開する方法を説明します。

ステップ5: ngrokを使用してサーバーをTwilioに公開

ローカルサーバーをインターネット上に公開するには、ngrokのようなサービス(あるいはVPSなど)を使用する必要があります。Twilioがお客様のサーバーにリクエストを送信し、指示を受け取るには、公開URLが必要です。

ngrokのダウンロードとインストールがまだの場合は、完了後に次のコマンドを実行してください。ポート番号を5050以外に変更している場合は、ここでも必ず同じ番号を指定してください。

ngrok http 5050

これで公開用URL(例:https://abc123.ngrok.io)を取得し、テストに使用できるようになります。この画面は次のような表示になりました。

ステップ6: Twilioの設定

ここまでくれば、あと一歩です。

Twilio Consoleにアクセスし、音声対応の番号を選択します。

Voice & Fax](音声とFAX)セクションで、[A Call Comes In](着信)WebhookをngrokのURLに設定します(Forwardingの行に表示されているURL、このスクリーンショットではhttps://ad745c4093d9.ngrok.appで、その末尾に/incoming-callを付けます)。例えば、私の例ではhttps://ad745c4093d9.ngrok.app/incoming-callと入力します。

変更を保存します。これが最後のステップです!

設定をテストしましょう!

ngrokセッションが実行中であり、サーバーが稼働していることを確認してください。確認できたら、携帯電話または固定電話からTwilio番号に電話をかけます。

サーバーは通話を処理し(TwiMLをTwilioに提供)、OpenAI Realtime APIとTwilioのWebSocketを中継します。話しかけてみてください。AIのシステムメッセージが聞こえ、対話できるはずです。

よくある問題とトラブルシューティング

設定に不備がある(ただしサーバーは稼働している)場合は、まず以下の点を確認してください。

  1. ngrokは動作していますか? Voice Configuration -> A Call Comes InセクションのURLは正しく設定されていますか?
  2. Twilio側のエラー(TwiMLの問題など)はありませんか?Twilioのエラーはいくつかの方法でデバッグできます。詳細はこちらの記事をご覧ください。
  3. コードはOpenAIを正しく呼び出していますか?詳細はOpenAIのドキュメントを参照してください。

まとめ

素晴らしい仕組みではないでしょうか。Twilio VoiceとOpenAI Realtime APIを用いた、AI音声アシスタントの構築に成功しました。これで、ユーザーの入力にほぼリアルタイムで応答できる、動的で低レイテンシーのインタラクティブ音声アプリケーションの完成です。必要なときにいつでも呼び出せる、信頼性の高い音声インターフェースを利用できるようになりました。

皆さんが構築したアシスタントと対話できる日を楽しみにしています。

次のステップ:

Paul KampはTwilioブログのチーフテクニカルエディターです。彼の連絡先はpkamp [at] twilio.comです(彼のAIアシスタントが対応するかもしれません)。

Dominik KundelはOpenAIのDeveloper Experience担当です。