メインコンテンツへスキップ
Twilio Programmable VoiceMedia Streams と組み合わせて使用することで、電話通話で Cartesia TTS によって生成された音声を WebSocket 経由で受信できます。このウォークスルーでは Node.js を使用し、Twilio のストリームを Cartesia にブリッジして TTS 音声を発信先の回線で再生する小さなサーバーを実装します。

前提条件

始める前に、以下のものが揃っていることを確認してください:
  1. Node.js がインストールされていること。
  2. Twilio アカウント。Account SID と Auth Token が必要です。
  3. Cartesia API キー
  4. 発信先の電話番号。
  5. 発信元となる Twilio の電話番号。
  6. ngrok authtoken (無料アカウントで構いません)。

はじめに

1

プロジェクトをセットアップする

  1. プロジェクト用に新しいディレクトリを作成し、ターミナルでそのディレクトリに移動します。
  2. 新しい Node.js プロジェクトを初期化します:
    npm init -y
    
  3. 必要な依存関係をインストールします:
    npm install twilio ws http @ngrok/ngrok dotenv
    
2

環境変数を設定する

プロジェクトのルートに .env ファイルを作成し、次の内容を追加します:
TWILIO_ACCOUNT_SID="your_twilio_account_sid"
TWILIO_AUTH_TOKEN="your_twilio_auth_token"
CARTESIA_API_KEY="your_cartesia_api_key"
NGROK_AUTHTOKEN="your_ngrok_authtoken"
プレースホルダーの値を実際の認証情報に置き換えてください。
3

メインスクリプトを作成する

app.js (または任意の名前) というファイルを作成し、次のコードを追加します:
const twilio = require('twilio');
const WebSocket = require('ws');
const http = require('http');
const ngrok = require('@ngrok/ngrok');
const dotenv = require('dotenv');
const crypto = require('crypto');

// Load environment variables
dotenv.config({ override: true });

// Function to get a value from environment variable or command line argument
function getConfig(key, defaultValue = undefined) {
  return process.env[key] || process.argv.find(arg => arg.startsWith(`${key}=`))?.split('=')[1] || defaultValue;
}

// Configuration
const config = {
    TWILIO_ACCOUNT_SID: getConfig('TWILIO_ACCOUNT_SID'),
    TWILIO_AUTH_TOKEN: getConfig('TWILIO_AUTH_TOKEN'),
    CARTESIA_API_KEY: getConfig('CARTESIA_API_KEY'),
    NGROK_AUTHTOKEN: getConfig('NGROK_AUTHTOKEN'),
};

// Validate required configuration
const requiredConfig = ['TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'CARTESIA_API_KEY', 'NGROK_AUTHTOKEN'];
for (const key of requiredConfig) {
    if (!config[key]) {
        console.error(`Missing required configuration: ${key}`);
        process.exit(1);
    }
}

const client = twilio(config.TWILIO_ACCOUNT_SID, config.TWILIO_AUTH_TOKEN);
4

Cartesia TTS を設定する

スクリプト内には、Cartesia TTS の設定セクションがあります。必要に応じて以下の変数を設定してください:
const TTS_WEBSOCKET_URL = `wss://api.cartesia.ai/tts/websocket?cartesia_version=2026-03-01`;
const modelId = 'sonic-3';
const voice = {
    'mode': 'id',
    // You can check available voices using the Cartesia API or at https://play.cartesia.ai
    'id': "e07c00bc-4134-4eae-9ea4-1a55fb45746b"
};
const partialResponse = 'Hi there, my name is Cartesia. I hope youre having a great day!';
5

Twilio の発信設定をする

Twilio のアウトバウンド/インバウンド番号を設定します:
const outbound = "+1234567890"; // Replace with the number you want to call
const inbound = "+1234567890";  // Replace with your Twilio number
6

メインロジックを実装する

main() 関数が全プロセスをオーケストレーションします:
  1. Cartesia TTS WebSocket に接続する
  2. TTS WebSocket をテストする
  3. Twilio WebSocket サーバーをセットアップする
  4. Twilio WebSocket 用の ngrok トンネルを作成する
  5. Twilio を使って通話を発信する
let ttsWebSocket;
let callSid;
let messageComplete = false;
let audioChunksReceived = 0;

function log(message) {
  console.log(`[${new Date().toISOString()}] ${message}`);
}

function connectToTTSWebSocket() {
  return new Promise((resolve, reject) => {
    log('Attempting to connect to TTS WebSocket');
    ttsWebSocket = new WebSocket(TTS_WEBSOCKET_URL, {
      headers: { 'X-Api-Key': config.CARTESIA_API_KEY }
    });

    ttsWebSocket.on('open', () => {
      log('Connected to TTS WebSocket');
      resolve(ttsWebSocket);
    });

    ttsWebSocket.on('error', (error) => {
      log(`TTS WebSocket error: ${error.message}`);
      reject(error);
    });

    ttsWebSocket.on('close', (code, reason) => {
      log(`TTS WebSocket closed. Code: ${code}, Reason: ${reason}`);
      reject(new Error('TTS WebSocket closed unexpectedly'));
    });
  });
}

function sendTTSMessage(message) {
  const textMessage = {
    'model_id': modelId,
    'transcript': message,
    'voice': voice,
    'output_format': {
      'container': 'raw',
      'encoding': 'pcm_mulaw',
      'sample_rate': 8000
    },
    // create a new context for each message since each is a complete transcript
    'context_id': crypto.randomUUID()
  };

  log(`Sending message to TTS WebSocket: ${message}`);
  ttsWebSocket.send(JSON.stringify(textMessage));
}

function testTTSWebSocket() {
  return new Promise((resolve, reject) => {
    const testMessage = 'This is a test message';
    let receivedAudio = false;

    sendTTSMessage(testMessage);

    const timeout = setTimeout(() => {
      if (!receivedAudio) {
        reject(new Error('Timeout: No audio received from TTS WebSocket'));
      }
    }, 10000); // 10 second timeout

    ttsWebSocket.on('message', (audioChunk) => {
      if (!receivedAudio) {
        log(audioChunk);
        log('Received audio chunk from TTS for test message');
        receivedAudio = true;
        clearTimeout(timeout);
        resolve();
      }
    });
  });
}

async function startCall(twilioWebsocketUrl) {
  try {
    log(`Initiating call with WebSocket URL: ${twilioWebsocketUrl}`);
    const call = await client.calls.create({
      twiml: `<Response><Connect><Stream url="${twilioWebsocketUrl}"/></Connect></Response>`,
      to: outbound,  // Replace with the phone number you want to call
      from: inbound  // Replace with your Twilio phone number
    });

    callSid = call.sid;
    log(`Call initiated. SID: ${callSid}`);
  } catch (error) {
    log(`Error initiating call: ${error.message}`);
    throw error;
  }
}

async function hangupCall() {
  try {
    log(`Attempting to hang up call: ${callSid}`);
    await client.calls(callSid).update({status: 'completed'});
    log('Call hung up successfully');
  } catch (error) {
    log(`Error hanging up call: ${error.message}`);
  }
}

function setupTwilioWebSocket() {
    return new Promise((resolve, reject) => {
      const server = http.createServer((req, res) => {
        log(`Received HTTP request: ${req.method} ${req.url}`);
        res.writeHead(200);
        res.end('WebSocket server is running');
      });

      const wss = new WebSocket.Server({ server });

      log('WebSocket server created');

      wss.on('connection', (twilioWs, request) => {
        log(`Twilio WebSocket connection attempt from ${request.socket.remoteAddress}`);

        let streamSid = null;

        twilioWs.on('message', (message) => {
          try {
            const msg = JSON.parse(message);
            log(`Received message from Twilio: ${JSON.stringify(msg)}`);

            if (msg.event === 'start') {
              log('Media stream started');
              streamSid = msg.start.streamSid;
              log(`Stream SID: ${streamSid}`);
              sendTTSMessage(partialResponse);
            } else if (msg.event === 'media' && !messageComplete) {
              log('Received media event');
            } else if (msg.event === 'stop') {
              log('Media stream stopped');
              hangupCall();
            }
          } catch (error) {
            log(`Error processing Twilio message: ${error.message}`);
          }
        });

        twilioWs.on('close', (code, reason) => {
          log(`Twilio WebSocket disconnected. Code: ${code}, Reason: ${reason}`);
        });

        twilioWs.on('error', (error) => {
          log(`Twilio WebSocket error: ${error.message}`);
        });

        // Handle incoming audio chunks from TTS WebSocket
        ttsWebSocket.on('message', (audioChunk) => {
          log('Received audio chunk from TTS');
          try {
            if (streamSid) {
              twilioWs.send(JSON.stringify({
                event: 'media',
                streamSid: streamSid,
                media: {
                  payload: JSON.parse(audioChunk)['data']
                }
              }));

              audioChunksReceived++;
              log(`Audio chunks received: ${audioChunksReceived}`);

              if (audioChunksReceived >= 50) {
                messageComplete = true;
                log('Message complete, preparing to hang up');
                setTimeout(hangupCall, 2000);
              }
            } else {
              log('Warning: Received audio chunk but streamSid is not set');
            }
          } catch (error) {
            log(`Error sending audio chunk to Twilio: ${error.message}`);
          }
        });

        log('Twilio WebSocket connected and handlers set up');
      });

      wss.on('error', (error) => {
        log(`WebSocket server error: ${error.message}`);
      });

      server.listen(0, () => {
        const port = server.address().port;
        log(`Twilio WebSocket server is running on port ${port}`);
        resolve(port);
      });

      server.on('error', (error) => {
        log(`HTTP server error: ${error.message}`);
        reject(error);
      });
    });
  }

async function setupNgrokTunnel(port) {
    try {
      const listener = await ngrok.forward({
        addr: port,
        authtoken: config.NGROK_AUTHTOKEN,
      });
      const wssUrl = listener.url().replace('https://', 'wss://');
      log(`ngrok tunnel established: ${wssUrl}`);
      return wssUrl;
    } catch (error) {
      log(`Error setting up ngrok tunnel: ${error.message}`);
      throw error;
    }
  }

async function main() {
  try {
    log('Starting application');

    await connectToTTSWebSocket();
    log('TTS WebSocket connected successfully');

    await testTTSWebSocket();
    log('TTS WebSocket test passed successfully');

    const twilioWebsocketPort = await setupTwilioWebSocket();
    log(`Twilio WebSocket server set up on port ${twilioWebsocketPort}`);

    const twilioWebsocketUrl = await setupNgrokTunnel(twilioWebsocketPort);

    await startCall(twilioWebsocketUrl);
  } catch (error) {
    log(`Error in main function: ${error.message}`);
  }
}

// Run the script
main();
7

アプリケーションを実行する

アプリケーションを実行するには、次のコマンドを使用します:
node app.js

仕組み

  1. スクリプトは Cartesia の TTS WebSocket への接続を確立します。
  2. Twilio と通信するためのローカル WebSocket サーバーをセットアップします。
  3. ローカルの WebSocket サーバーをインターネットに公開するための ngrok トンネルを作成します。
  4. Twilio を使って通話を発信し、ngrok トンネルに接続します。
  5. 通話が接続されると、スクリプトは事前に定義したメッセージを Cartesia の TTS に送信します。
  6. Cartesia はテキストを音声に変換し、音声チャンクを返します。
  7. スクリプトはこれらの音声チャンクを Twilio に転送し、Twilio が通話上で再生します。

カスタマイズ

  • 読み上げるメッセージを変更するには、partialResponse 変数を変更します。
  • TTS のボイス特性を変えるには、voice オブジェクトのボイスパラメーターを調整します。
  • 通話を終了するタイミングを制御するには、audioChunksReceived のしきい値を変更します。

トラブルシューティング

  • 問題が発生した場合は、コンソールログで詳細なエラーメッセージを確認してください。
  • 必要な環境変数がすべて正しく設定されていることを確認してください。
  • invalid tunnel configuration と表示される場合は、ngrok ではなく、よりサポートが充実した @ngrok/ngrok パッケージを使用していることを確認してください。