🐷

【openAI realtime API】realtime consoleを解読した

2024/10/06に公開

2024年10月1日にopenAIからRealtime APIが発表されました
https://openai.com/index/introducing-the-realtime-api/

エンジニアの皆さんは既に試されたと思いますが、この応答性の速さはやばいですね。

ちなみに私はAIと音声でやりとりするアプリを製作中で、少しでもAIの応答性を早くしようとGoogleのspeech to textのv2で音声のストリーミング翻訳を使えるようにしたのですが、その努力が何だったんだろうというくらいのレベルの高さです。

Realtime APIはベータ版とはいうものの、APIが使えるようになっていまして、公式ドキュメントやサンプルコードも公開されています。

今回私が取り組んだサンプルコードはこちら(以下見本コードと呼びます)
https://github.com/openai/openai-realtime-api-beta

実際に動かしてもらうとわかりますが、地図やらなんやらてんこ盛りで、これはこれですごいのですが、完成品だけ見せられてもどこがどう動いているのかわかりにくいです。

そこで、手元でアプリを制作すべく、必要最小限な機能を持つアプリを作成しました。

開発準備

Reactでアプリを作成してください。
(お前Vue派やったんちゃうんか!Reactエンジニアのこと散々ディスってたやろというツッコミはなしでお願いします。Reactに魂売りました。)

今回インストールするライブラリは以下です

npm i @openai/realtime-api-beta

※ react-router-domやtailwind等は各々インストール、設定してください

src下にpages/AudioTalk.tsxとlibフォルダを作成してください。
AudioTalk.tsxで開発していきます。

libフォルダには、見本 https://github.com/openai/openai-realtime-api-beta からwabtoolsをフォルダごとコピってください。

src/lib
└── wavtools
    ├── dist
    ├── index.js
    └── lib

このwavtoolsは多分openAIが独自開発した音声録音や再生用のライブラリです。
JavaScript系の音声ライブラリは色々転がっていますが、他のライブラリを使うとrealtime APIに放り込めませんでした。
ここは素直にopenAI様がお作りなされたライブラリをコピーして使いましょう

コード全体

クリックして展開
import { useEffect, useCallback, useState } from 'react';

import { WavRecorder, WavStreamPlayer } from '../lib/wavtools/index.js';

import { RealtimeClient } from '@openai/realtime-api-beta';
import { ItemType } from '@openai/realtime-api-beta/dist/lib/client.js';


export default function AudioTalk (){
    const client = new RealtimeClient({ 
        apiKey: 'sk-*************************',
        dangerouslyAllowAPIKeyInBrowser: true 
    })

    const wavRecorder = new WavRecorder({ sampleRate: 24000 })
    const wavStreamPlayer = new WavStreamPlayer({ sampleRate: 24000 })

    const [isConnected, setIsConnected] = useState(false)
    const [items, setItems] = useState<ItemType[]>([]);


    const connectConversation = useCallback(async () => {

        setIsConnected(true);
        setItems(client.conversation.getItems());

        await wavRecorder.begin()

        await wavStreamPlayer.connect();

        await client.connect();

        client.sendUserMessageContent([
            {
            type: `input_text`,
            text: `Hello!`,
            },
        ]);
    
        if (client.getTurnDetectionType() === 'server_vad') {
            await wavRecorder.record((data) => client.appendInputAudio(data.mono));
        }                
    },[])

    const disconnectConversation = useCallback(async () => {
        setIsConnected(false);
        setItems([]);
    
        client.disconnect();
    
        await wavRecorder.end();
    
        await wavStreamPlayer.interrupt();
    }, []);

    /**
   * Core RealtimeClient and audio capture setup
   * Set all of our instructions, tools, events and more
   */
  useEffect(() => {

    // Set instructions
    client.updateSession({ instructions: 'あなたは役にたつAIアシスタントです' });
    // Set transcription, otherwise we don't get user transcriptions back
    client.updateSession({ input_audio_transcription: { model: 'whisper-1' } });
    // ユーザーが話終えたことをサーバー側で判断する'server_vad'に設定。('manual'モードもある)
    client.updateSession({turn_detection:{type:'server_vad'}})

    // 不要
    client.on('error', (event: any) => console.error(event));

    // 必要
    client.on('conversation.interrupted', async () => {
      console.log('interrupted')
      const trackSampleOffset = await wavStreamPlayer.interrupt();
      if (trackSampleOffset?.trackId) {
        const { trackId, offset } = trackSampleOffset;
        await client.cancelResponse(trackId, offset);
      }
    });

    // 必要
    client.on('conversation.updated', async ({ item, delta }: any) => {
      console.log('convesation.updated')
      const items = client.conversation.getItems();
      console.log('items',items)
      if (delta?.audio) {
        wavStreamPlayer.add16BitPCM(delta.audio, item.id);
      }
      if (item.status === 'completed' && item.formatted.audio?.length) {
        const wavFile = await WavRecorder.decode(
          item.formatted.audio,
          24000,
          24000
        );
        item.formatted.file = wavFile;
      }
      setItems(items);
    });

    setItems(client.conversation.getItems());

    return () => {
      // cleanup; resets to defaults
      client.reset();
    };
  }, []);

    

    return (
        <>
            <h1>最小限のrealtime APIを試す</h1>

            <div>
            {items.map(item=>{
                return (<>
                <div>{item.role}{JSON.stringify(item.formatted.transcript)}</div>
                </>)
            })}
            </div>

            <div>
            {isConnected? 
                <button onClick={disconnectConversation}>停止</button>:
                <button onClick={connectConversation}>録音開始</button>
            }

            </div>
            
        </>
    )
}

AudioTalk.tsxの解説

ライブラリ

最終的に使うシンプルなライブラリは以下だけです

import { useEffect, useRef, useCallback, useState } from 'react';

// さっきコピったopenAIオリジナルのライブラリ
import { WavRecorder, WavStreamPlayer } from '../lib/wavtools/index.js';

// 以下2つがrealtime-apiのライブラリ
import { RealtimeClient } from '@openai/realtime-api-beta';
import { ItemType } from '@openai/realtime-api-beta/dist/lib/client.js';

応答と録音のインスタンス

AI応答のclient、音声の録音や再生するためのライブラリは以下の通りインスタンス化します。
apiKeyをベタ打ちするときはdangerouslyAllowAPIKeyInBrowser: trueを設定する必要があります

const client = new RealtimeClient({ 
    apiKey: 'sk-************************',
    dangerouslyAllowAPIKeyInBrowser: true 
})

const wavRecorder = new WavRecorder({ sampleRate: 24000 })
const wavStreamPlayer = new WavStreamPlayer({ sampleRate: 24000 })

ちなみに見本コードではuseRefを使っていますが、使わなくても動きます。

状態管理変数

状態として管理すべきなのは、最低限以下2つ。
(ItemType型はインストールしたopenaiライブラリからインポートされています)

// サーバーと接続しているか、切断しているか
const [isConnected, setIsConnected] = useState(false)

// itemはメッセージデータのオブジェクト。発言履歴やrole、status、typeなどを含みます。
const [items, setItems] = useState<ItemType[]>([]);

リアルタイム会話をセットアップする関数

この関数が一番大事です。(見本コードにもCore RealtimeClient and audio capture setup Set all of our instructions, tools, events and moreとコメントが書いてあります)

見本コードではtoolを追加するなどあれこれ設定していましたが、単純なトーク機能だけなら以下のコードで動きます。

  useEffect(() => {

    // AIへの指示をセット
    client.updateSession({ instructions: 'あなたは役にたつAIアシスタントです' });
    // 音声toテキスト翻訳のモデルをセット
    client.updateSession({ input_audio_transcription: { model: 'whisper-1' } });
    // ユーザーが話終えたことをサーバー側で判断する'server_vad'に設定。('manual'モードもある)
    client.updateSession({turn_detection:{type:'server_vad'}})

    // この後各種イベントハンドラーで処理する(後述)
    ・・・

    // 会話のオブジェクトはclient.conversationに含まれているので、抽出してitemsにセットする
    setItems(client.conversation.getItems());

    return () => {
      // cleanup; resets to defaults
      client.reset();
    };
  }, []);

clientのイベントハンドラー conversation.updated

会話が更新されたら発火するイベントで、一番大事です。

ストリーミング録音なので、ユーザーのメッセージは'こんに','ち','は'というようにバラバラに録音&翻訳されます。
話し終えたと判断されるとitem.statusが'completed'になるので、そのタイミングでitemオブジェクトをsetItemsします

    client.on('conversation.updated', async ({ item, delta }: any) => {
      const items = client.conversation.getItems();
      if (delta?.audio) {
        wavStreamPlayer.add16BitPCM(delta.audio, item.id);
      }
      if (item.status === 'completed' && item.formatted.audio?.length) {
        const wavFile = await WavRecorder.decode(
          item.formatted.audio,
          24000,
          24000
        );
        item.formatted.file = wavFile;
      }
      setItems(items);
    });

この他にも見本コードには色々イベントハンドラーがありましたので、必要に応じて追加していくと良いでしょう

connectConversationとdisconnectConversation

connectボタンを押されたときに実行される関数です。

やっていることは以下のコードのコメント見てください。

const connectConversation = useCallback(async () => {

    // 状態を変更
    setIsConnected(true);
    setItems(client.conversation.getItems());

    // ストリーミング録音・再生を開始
    await wavRecorder.begin()
    await wavStreamPlayer.connect();

    // clientを接続
    await client.connect();

    client.sendUserMessageContent([
        {
            type: `input_text`,
            text: `こんにちは!`,
        },
    ]);

    // リアルタイム会話の時に実行される。自動的にwavRecorderが録音した音声データを随時clientに加えます。
    if (client.getTurnDetectionType() === 'server_vad') {
        await wavRecorder.record((data) => client.appendInputAudio(data.mono));
    }                
},[])

jsxを返す部分

必要最小限に絞るため、色気も素っ気もないシンプルなコードです。
(JSXの書き方は割愛します)

    return (
        <>
            <h1>最小限のrealtime APIを試す</h1>

            <div>
            {items.map(item=>{
                return (<>
                <div>{item.role}{JSON.stringify(item.formatted.transcript)}</div>
                </>)
            })}
            </div>

            <div>
            {isConnected? 
                <button onClick={disconnectConversation}>停止</button>:
                <button onClick={connectConversation}>録音開始</button>
            }

            </div>
            
        </>
    )

Discussion