🗣️

OpenAI Realtime APIで音声チャットしてみる

2025/03/21に公開

やや今更感もありますが去年の10月に公開されたOpenAIのRealtime APIが気になっていたので軽く触ってみました。

Realtime APIとは

https://platform.openai.com/docs/guides/realtime

文字通りリアルタイムなやり取りに特化したAPIです。モデルも専用に用意されており、2025年3月
現在 gpt-4o-realtime-preview-2024-12-17gpt-4o-mini-realtime-preview-2024-12-17 が利用できます。

双方向なインタラクションだけでなく、マルチモーダルが標準でサポートされているのが嬉しいポイントです。2025年3月現在ではテキストと音声が入出力の方法で利用できます。Function Callingもサポートされているようで、エージェント的な振る舞いも実装させることができそうです。

触ってみる

WebRTCとWebSocketの2つの方式がサポートされています。WebRTCはあまり馴染みがなかったので今回はWebSocketを使いました。
「医療アシスタント」をテーマに、ユーザーからの音声入力に対して音声で返答するような簡単なプログラムを実装しています。

import WebSocket from "ws";
import SpeakerNode from "speaker";
import record from "node-record-lpcm16";

// 音声の再生
class AudioSpeaker {
  private speaker: SpeakerNode;
  private audioQueue: Buffer[] = [];
  private isPlaying = false;

  constructor() {
    this.speaker = new SpeakerNode({
      channels: 1,
      bitDepth: 16,
      sampleRate: 24000,
    });
  }

  private async playAudioData(audioData: Buffer): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      if (!this.speaker.write(audioData)) {
        this.speaker.once("drain", () => resolve());
      } else {
        resolve();
      }
      this.speaker.once("error", (err) => reject(err));
    });
  }

  async processAudioQueue(): Promise<void> {
    if (this.isPlaying || this.audioQueue.length === 0) return;

    this.isPlaying = true;
    while (this.audioQueue.length > 0) {
      const audioData = this.audioQueue.shift();
      if (audioData) {
        await this.playAudioData(audioData);
      }
    }
    this.isPlaying = false;
  }

  addToQueue(audioData: Buffer): void {
    this.audioQueue.push(audioData);
    this.processAudioQueue();
  }
}

// 音声の入力
class Microphone {
  private static readonly SILENCE_THRESHOLD = 0.2;
  private static readonly SILENCE_DURATION = 1000;
  private lastAudioTime: number = Date.now();
  private isCurrentlySpeaking = false;
  private recorder: any;

  constructor(private ws: WebSocket) {
    this.recorder = record.record({
      sampleRate: 24000,
      channels: 1,
      audioType: "raw",
      compress: false,
    });
  }

  startRecording(): void {
    const stream = this.recorder.stream();

    stream
      .on("data", (chunk: Buffer) => this.handleAudioData(chunk))
      .on("error", console.error);

    process.on("SIGINT", () => {
      console.log("録音を停止します...");
      this.recorder.stop();
      process.exit();
    });
  }

  private handleAudioData(chunk: Buffer): void {
    const float32Array = this.convertToFloat32Array(chunk);
    const audioLevel = Math.max(...float32Array.map(Math.abs));
    const currentTime = Date.now();

    this.handleSpeechDetection(audioLevel, currentTime);

    // ポイント② 音声データをchunkごとにinput_audio_buffer.appendで追加
    if (this.isCurrentlySpeaking) {
      const base64Chunk = base64EncodeAudio(float32Array);
      this.ws.send(
        JSON.stringify({
          type: "input_audio_buffer.append",
          audio: base64Chunk,
        })
      );
    }
  }

  private handleSpeechDetection(audioLevel: number, currentTime: number): void {
    if (audioLevel > Microphone.SILENCE_THRESHOLD) {
      this.lastAudioTime = currentTime;
      if (!this.isCurrentlySpeaking) {
        this.isCurrentlySpeaking = true;
        console.log("発話開始を検出しました");
      }
    } else if (
      this.isCurrentlySpeaking &&
      currentTime - this.lastAudioTime > Microphone.SILENCE_DURATION
    ) {
      console.log("発話終了を検出しました");
      this.isCurrentlySpeaking = false;
      // ポイント③ 音声データの入力を完了して回答を生成
      this.ws.send(JSON.stringify({ type: "input_audio_buffer.commit" }));
      this.ws.send(JSON.stringify({ type: "response.create" }));
    }
  }

  private convertToFloat32Array(chunk: Buffer): Float32Array {
    const float32Array = new Float32Array(chunk.length / 2);
    for (let i = 0; i < chunk.length; i += 2) {
      float32Array[i / 2] = chunk.readInt16LE(i) / 0x7fff;
    }
    return float32Array;
  }
}

async function main() {
  const url =
    "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17";

  const ws = new WebSocket(url, {
    headers: {
      Authorization: "Bearer " + process.env.OPENAI_API_KEY,
      "OpenAI-Beta": "realtime=v1",
    },
  });

  const speaker = new AudioSpeaker();
  const microphone = new Microphone(ws);

  ws.on("open", async () => {
    console.log("Connected to server.");
    console.log("Listening for microphone input...");

    ws.send(
      JSON.stringify({
        type: "session.update",
        session: {
          // ポイント① 書き起こし、システムプロンプトの設定
          input_audio_transcription: {
            model: "whisper-1",
            language: "ja",
          },
          instructions:
            "あなたは医療のエキスパートで、ユーザーからの病気や体調に関する相談に答えることが仕事です。" +
            "ユーザーの質問に対して、わかりやすさ、寄り添い、情報の正確性を意識して、100文字以内を目安に回答してください。" +
            "ユーザーは日本語で話します。あなたも必ず日本語で返答してください。",
        },
      })
    );

    microphone.startRecording();
  });

  // ポイント④ サーバーイベントとして生成された音声を受け取り、再生
  ws.on("message", (message) => {
    const event = JSON.parse(message.toString());

    if (event.type === "response.audio.delta") {
      try {
        const audioData = Buffer.from(event.delta, "base64");
        speaker.addToQueue(audioData);
      } catch (error) {
        console.error("Error processing audio delta:", error);
      }
    } else {
      console.log(event);
    }
  });
}

// 音声データの変換ユーティリティ関数
function floatTo16BitPCM(float32Array: Float32Array): ArrayBuffer {
  const buffer = new ArrayBuffer(float32Array.length * 2);
  const view = new DataView(buffer);
  let offset = 0;
  for (let i = 0; i < float32Array.length; i++, offset += 2) {
    let s = Math.max(-1, Math.min(1, float32Array[i]));
    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
  }
  return buffer;
}

function base64EncodeAudio(float32Array: Float32Array): string {
  const arrayBuffer = floatTo16BitPCM(float32Array);
  let binary = "";
  const bytes = new Uint8Array(arrayBuffer);
  const chunkSize = 0x8000;
  for (let i = 0; i < bytes.length; i += chunkSize) {
    const chunk = bytes.subarray(i, i + chunkSize);
    binary += String.fromCharCode.apply(null, chunk as any);
  }
  return btoa(binary);
}

// アプリケーションの起動
main().catch(console.error);

使い方はシンプルで、WebSocketのセッションを立ててそこでイベントのやり取りを行います。
クライアントイベントと呼ばれるプログラムからのリクエストに対応するイベントをセッションに送ることで、レスポンスがサーバーイベントとして非同期的に返ってくる感じです。
簡単にポイントをまとめました。

ポイント① 書き起こし、システムプロンプトの設定

session.update というクライアントイベントで書き起こしのモデル・言語やシステムプロンプトを設定することができます。これを設定しないと日本語で入力しても正しく認識されなかったり英語で返されたりするので注意が必要です。

ポイント② 音声データをchunkごとにinput_audio_buffer.appendで追加

音声の場合は input_audio_buffer.append で音声入力を複数回に分けて追加していきます。 conversation.item.create でまとめて入力することもできますが、マイク経由でリアルタイムでやり取りする場合は input_audio_buffer.append を使いストリーミング的に処理した方が遅延が少なくて済みます。

ポイント③ 音声データの入力を完了して回答を生成

input_audio_buffer.commit イベントで音声入力の完了を伝え、 response.create イベントで回答の生成をリクエストします。

ポイント④ サーバーイベントとして生成された音声を受け取り、再生

レスポンスも一度にまとめて送られるのではなく、 response.audio.delta というサーバーイベントで細切れで送られてくるのでこれをストリーミング的に受け取り、都度キューに入れて再生します。
他にも書き起こし文のストリームとして response.audio_transcript.delta や処理全体の完了を表す response.done などのイベントも送られてくるので、これらを必要に応じて組み合わせる
ことが可能です。

https://youtu.be/f_MfD4QtwC8

実行結果がこちらです。^1
何よりも驚きだったのはその応答速度で、喋り終わるのとほぼ同時に返答を生成できており、ラグに関してはほとんど気にならない程度になっています。生成されている音声も日本語ながらかなり自然です。
ただし、音声認識の精度に関してはbaseモデルとかなのかちょくちょくちゃんと聞き取ってくれない印象を受けました。large-v3あたりを使えるようになると改善されそう...?

おわりに

以前音声アシスタントを自作した際はラグが気になるところだったのですが、Realtime APIでは応答速度がかなり優秀で読み上げ音声も自然なためストレスなくやり取りできるなぁと感動しました。APIもストリーミング周りの処理が取り回しやすく設計されている印象で、音声アシスタント的なものを作りたい場合にはまず第一選択肢としていい完成度なのではないかと思いました。

Geminiの方もLive APIを出しており、こちらは画像も扱えるとのことだったので気が向いたらこちらも触ってみようと思います。
https://ai.google.dev/gemini-api/docs/live?hl=ja

Discussion