Twilio ConversationRelay の会話を止めずに Gemini で名前の読みをカタカナに書き起こしてみた

Twilio ConversationRelay の会話を止めずに Gemini で名前の読みをカタカナに書き起こしてみた

Twilio ConversationRelay の日本語の音声認識では、名前をカタカナで書き起こすといった表記の制御ができません。そこで会話を保ったまま、名前を伺う区間の声だけを Media Streams で受け取り、Gemini でカタカナに書き起こしました。名前の読みをカタカナで取得し、同じ会話へ戻せました。
2026.10.14

はじめに

Twilio ConversationRelay の日本語の音声認識は、名前を漢字で書き起こします。たとえば「ショウジ アキト」は「東海林明人」と書き起こされることがあり、文字列からは、姓が「ショウジ」か「トウカイリン」か、名が「アキト」か「アキヒト」かが分かりません。名前の読みで予約を探す業務では、読みが必要です。

Twilio ConversationRelay 側の設定には、書き起こしを読み (カタカナ) で返させる指定がありません。そこで本記事では、ConversationRelay で組んだ電話の受付はそのまま使い、名前の部分だけ声を別に受け取って Gemini に渡し、読みをカタカナで取得する方法を試しました。

検証環境

  • Twilio ConversationRelay (音声認識: Deepgram nova-3、言語: ja-JP)
  • Twilio Media Streams (一方向ストリーム)
  • Vertex AI の Gemini (gemini-2.5-flash、asia-northeast1)
  • Node.js (http と ws) の検証用サーバー 1 プロセス

対象読者

  • 電話の受付を AI に任せ、名前で予約や記録を突き合わせる業務を支えている方
  • Twilio ConversationRelay で日本語の電話 AI を作っている方

参照

背景

ConversationRelay の音声認識の主な設定は、プロバイダー・モデル・言語・hints です (2026 年 10 月時点)。hints は指定した語を認識されやすくするもので、表記を変えるものではありません。hints に存在する全ての名前を並べて対応することもできません。モデルを Deepgram の nova-3 から flux にかえても、名前は漢字で届きます。

また、ConversationRelay をやめて音声の処理を丸ごと自前で組む方法もありますが、すでに動いているものを大きく作り変えることになります。

そこで、書き起こしに Gemini を使いました。Gemini を使ったのは、音声と指示文を一緒に受け取れるためです。「聞こえた音のとおりにカタカナで」と頼めます。

構成

ConversationRelay の WebSocket 接続はそのまま保ち、名前を伺う直前に Media Streams の一方向ストリームを REST API で開始します。名乗りが終わったらストリームを止め、たまった音声を WAV にして Gemini へ送ります。返ってきたカタカナは、最初からつながっている ConversationRelay の接続へ text で送ります。

検証用サーバーは、WebSocket を受け続ける常時起動の Node.js 1 プロセスで、パスごとに受け口を分けました。

  • /ws: ConversationRelay の WebSocket (会話用)。通話の最初から最後までつながったまま
  • /ms: Media Streams の WebSocket (名前の声用)。ストリームを開始するたびにつながり、停止すると閉じる

実装

要点だけ説明していきます。

ストリームの開始と停止

ストリームは、通話中に REST API の Streams リソースで開始と停止ができます。ConversationRelay の接続には触れないので、会話はそのまま続きます。

start-stream.js
const res = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}/Streams.json`, {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    Url: `wss://${host}/ms`,
    Track: 'inbound_track',
    'Parameter1.Name': 'k',
    'Parameter1.Value': sharedKey,
    'Parameter2.Name': 'label',
    'Parameter2.Value': streamName,
  }),
});
const { sid } = await res.json();

// 名乗りが終わったら止める
await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls/${callSid}/Streams/${sid}.json`, {
  method: 'POST',
  headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ Status: 'stopped' }),
});

Track は inbound_track にしました。お客様の声だけが届き、AI の読み上げは混ざりません。/ms では、media の payload (base64 の mu-law) をためていき、stop で音声を確定します。

カスタムパラメーターに name という名前を使わない

最初は、どの名前を伺っているかを name というカスタムパラメーターで渡していました。すると、ストリームの作成が HTTP 500 (エラーコード 20500) で失敗しました。

原因を切り分けるため、ConversationRelay を使わない通話で、パラメーターの組み合わせを変えて試しました。

カスタムパラメーターの名前 一緒に送ったもの 結果
name Url だけ 作成できるが、start の customParameters に name の値が届かない
name Track・Name・StatusCallback HTTP 500 (20500)
label Track・Name・StatusCallback のそれぞれ単独と、全部 4 通りとも作成でき、値も届く

ConversationRelay との組み合わせの問題ではなく、カスタムパラメーターの名前が原因でした。

ストリームの URL にクエリ文字列を付けない

/ms を受けるサーバーは外部に公開するので、Twilio からの接続かどうかを確かめたくなります。最初は照合用の値を URL のクエリ文字列に付けましたが、エラー 31920 / 31922 でストリームが張られませんでした。

照合用の値は、カスタムパラメーター (上のコードの k) で渡します。値は /ms に届く start イベントの customParameters に入っているので、そこで確かめて、合わなければ接続を閉じます。

受け取りの準備ができたことを start で確かめる

ストリームを作ってから最初の音声 (media イベント) が届くまでの時間はまちまちでした。作成から約 1.3 秒で届いた回もあれば、10 秒前後かかった回もあります。遅い回は、発信者が話し始めた時点で届き始めているように見えました。

最初の media を待ってから名前を伺う作りにすると、質問が 10 秒近く遅れることがあります。そこで、start イベントの受信を準備完了の合図にし、受け取ってから名前を伺うようにしました。名乗りの頭が欠けることはありませんでした。

待ち時間を埋める一言

名乗りの最終 prompt (last=true) が届いたら、すぐに「確認いたします。」を text で送ります。そのあとストリームを止め、Gemini を呼びます。Gemini を待つあいだも、お客様には AI の声が聞こえています。

finish-name.js
// 名乗りの最終 prompt を受けたら、先に一言だけ読み上げる
send({ type: 'text', token: '確認いたします。', last: true, lang: 'ja-JP' });
await stopStream(sid);
const wav = wavFromPcm(mulawToPcm(Buffer.concat(chunks)));
const kana = await geminiKana(wav);
send({ type: 'text', token: `${kana}様でよろしいでしょうか。`, last: true, lang: 'ja-JP' });

音声を WAV にする

Media Streams の音声は mu-law 8kHz・モノラルです。16bit PCM に戻し、44 バイトの WAV ヘッダーを付けました。

mu-law から WAV への変換
wav.js
function mulawToPcm(u8) {
  const out = Buffer.alloc(u8.length * 2);
  for (let i = 0; i < u8.length; i++) {
    const u = ~u8[i] & 0xff;
    const sign = u & 0x80;
    const exponent = (u >> 4) & 0x07;
    const mantissa = u & 0x0f;
    let sample = ((mantissa << 3) + 0x84) << exponent;
    sample -= 0x84;
    out.writeInt16LE(sign ? -sample : sample, i * 2);
  }
  return out;
}

function wavFromPcm(pcm, rate = 8000) {
  const h = Buffer.alloc(44);
  h.write('RIFF', 0);
  h.writeUInt32LE(36 + pcm.length, 4);
  h.write('WAVE', 8);
  h.write('fmt ', 12);
  h.writeUInt32LE(16, 16);
  h.writeUInt16LE(1, 20);
  h.writeUInt16LE(1, 22);
  h.writeUInt32LE(rate, 24);
  h.writeUInt32LE(rate * 2, 28);
  h.writeUInt16LE(2, 32);
  h.writeUInt16LE(16, 34);
  h.write('data', 36);
  h.writeUInt32LE(pcm.length, 40);
  return Buffer.concat([h, pcm]);
}

Gemini へのリクエスト

ファイルを別にアップロードせず、WAV を base64 にしてリクエスト本文に埋め込みました。 指示文と同じ user のターンで送ります。名乗り約 4 秒の WAV は約 65KB で、インラインで送れる 20MB の上限に収まります。

request.json
{
  "contents": [{
    "role": "user",
    "parts": [
      { "inlineData": { "mimeType": "audio/wav", "data": "<WAV の base64>" } },
      { "text": "<下の指示文>" }
    ]
  }],
  "generationConfig": { "temperature": 0, "thinkingConfig": { "thinkingBudget": 0 } }
}

指示文は次のとおりです。(※システムプロンプトは使っていません。)

prompt.txt
この音声は、電話でお客様が自分の名前を名乗った部分です。聞こえた音のとおりに、姓と名をカタカナで書き起こしてください。漢字やローマ字に変換しないでください。アルファベットを1文字ずつ読み上げている場合は、その綴りを日本語の読みとしてカタカナにしてください。出力はカタカナの名前だけを1行で、姓と名の間に半角スペースを1つ入れてください。

検証結果

同じ名乗りが、ConversationRelay と Gemini で次のようになりました。実在の方の名前で試したため、名前そのものは伏せて形だけを示します。

経路 結果の形
ConversationRelay (Deepgram nova-3) 名乗った読みとは別の読みになる漢字の姓と、カタカナの名に「です。」が付いた文
Media Streams → Gemini 名乗った音どおりのカタカナの姓と名 (姓と名の間に半角スペース)

ストリームの開始と停止の前後で、ConversationRelay の会話は途切れませんでした。早口の回では、Gemini も読みを誤ることがありました。

時間の内訳は次のとおりです。

区間 時間 (秒、約)
話し終わりから、ConversationRelay の最終 prompt まで 3.2
最終 prompt から「確認いたします。」の読み上げ開始まで 0.3
最終 prompt から、Gemini の結果を ConversationRelay へ返すまで 1.3

Gemini を待つ約 1.3 秒は、「確認いたします。」の読み上げのバックグラウンドにできました。

考察

この方法は、何を伺ったかが分かっている発話に使えると考えています。名前のほか、都市名を伺ったあとに「都市名として何を言っているか」と指示すれば、地名も文脈つきで書き起こせるでしょう。ConversationRelay の会話の処理には手を入れず、名前の区間の前後に処理を足すだけで導入できる点も利点です。

一方で、読みの正しさは保証できません。珍しい名前や早口では Gemini も誤るため、AI が読みを復唱してお客様に確かめていただく流れが前提になると思います。また、話し終わりから文字起こしが確定するまでの約 3.2 秒は縮まりません。

また、ConversationRelay の受け口をサーバーレスで作っている場合でも、/ms のように Media Streams の WebSocket を受け続けるサーバーを別に動かしておく必要があるという課題もあります。

なお、費用の上乗せは小さいと見込んでいます。名乗り約 4 秒の音声は約 130 トークン (1 秒あたり 32 トークン) で、指示文と出力を合わせても数百トークンのため、Gemini の費用は 1 回あたり 1 円に届かないでしょう。Media Streams も分単位の課金で、ConversationRelay の通話料と比べれば小さいと思いますが、単価は導入前に各社の公式の料金表で確認してください。

まとめ

Twilio ConversationRelay の日本語の音声認識では、名前の表記を制御できません。そこで、ConversationRelay の会話を止めずに、名前を伺う区間の声だけを Media Streams で受け取って Gemini に渡したところ、読みをカタカナで取得して同じ会話へ戻せました。本記事が、電話の AI で名前の読みを扱う方の参考になれば幸いです。


Twilioの導入支援はクラスメソッドにお任せください!

クラスメソッドでは、Twilioの導入から運用までしっかりサポートいたします。
コミュニケーションツールの導入や最適化をお考えの方は、ぜひお気軽にご相談ください。

Twilioの詳細を見る

この記事をシェアする

関連記事