虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

【Phaser3 + Whisper + OpenAI API】ブラウザゲームを音声認識で動かしてみた

記事タイトル画像

本記事は2024 夏のブログ連載企画の8日目の記事になります。
前回は古賀さんによる「Vercel公式のNext.js向けのChatbotテンプレートを試してみた!」が投稿されています。 
次回はS.Aさんによる「GPT-4o の画像解析でレシートを読み取る bot を作ってみた」が投稿される予定です。

目次

はじめに

皆さん、こんにちは。虎の穴ラボのT.Hです!

今回はAIを使って音声認識ゲームを作れないか?という思いつきを検証してみた結果をご紹介します。
まずは実際に動いている様子をご覧ください!
※下部の白文字は後から編集で入れています。

ゲーム画面のスクリーンショット

アーキテクチャ

  • Phaser3:JavaScriptベースのブラウザゲームフレームワークで、ゲームの画面構築や操作を担当します。
  • Flask:Pythonベースの軽量Webフレームワークで、サーバーサイドの処理を担当します。
  • Whisper:OpenAI製の音声認識モデルで、音声をテキストに変換します。
  • Function calling:OpenAI APIで利用できる機能で、音声認識結果から適切なゲーム操作を決定します。

検証環境

  • OS:Windows 11
  • ブラウザ:Google Chrome

それでは、処理の順に沿って実装を解説していきます!

ブラウザで音声を録音する

録音にはブラウザのMediaStream Recording APIを利用します。
録音開始と同時にaudioChunksにデータを溜め込み、終了時にBlob変換してサーバーに送信するように初期化します。

  // 録音の準備
  navigator.mediaDevices
    .getUserMedia({ audio: true })
    .then((stream) => {
      mediaRecorder = new MediaRecorder(stream);

      mediaRecorder.ondataavailable = (event) => {
        audioChunks.push(event.data);
      };

      mediaRecorder.onstop = () => {
        const audioBlob = new Blob(audioChunks, { type: "audio/wav" });
        audioChunks = [];
        sendAudioToServer(audioBlob);
      };
    })
    .catch((error) => {
      console.error("Error:", error);
    });

録音の開始はPhaser3の機能を利用して、スペースキー押下と紐づけます。

// スペースキーで録音開始
this.input.keyboard.on("keydown-SPACE", startRecording);
  
// 省略

// 2秒間録音する
function startRecording() {
  if (mediaRecorder && mediaRecorder.state === "inactive") {
    mediaRecorder.start();
    setTimeout(() => {
      mediaRecorder.stop();
    }, 2000);
  }
}

サーバーに音声データを送信する

Blobに変換した音声データをフォームデータとしてPOSTします。

// サーバーに録音データを送信する
async function sendAudioToServer(blob) {
  const formData = new FormData();
  formData.append("audio", blob);

  try {
    const response = await fetch("/api/action", {
      method: "POST",
      body: formData,
    });

Whisperで音声をテキスト変換する

サーバー側で受け取った音声データをファイルとして一時保存します。
その後、ファイルをWhisperに渡してテキスト変換します。
変換精度よりも速度の方が重要なため、モデルはbaseを選択しています。
多少の誤変換はChatGPTで吸収されるためか、baseモデルでも動作には問題ありませんでした。

model = whisper.load_model("base")

# 省略

# Whisperで音声認識を行う
audio = request.files["audio"]
audio.save("tmp/temp.wav")
result = model.transcribe("tmp/temp.wav")
text = result["text"]

Function callingで実行する関数を決定する

テキストを元に実行する関数を決定するため、LangChainを通してOpenAI APIのFunction callingを利用します。
短文しか送らない前提であり精度は重要ではないため、3.5-turboモデルを選択しています。
invokeに入力テキストと一緒に利用できる関数の名称と説明のリストを渡すことで、Function callingにより利用すべき関数と引数を取得することができます。

# LLMの初期化
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
self.model = ChatOpenAI(openai_api_key=OPENAI_API_KEY, model="gpt-3.5-turbo", temperature=0)

# 省略

# 利用できる関数の説明書
functions = [
    {"name": "move_up", "description": "Move the player up"},
    {"name": "move_down", "description": "Move the player down"},
    {"name": "move_left", "description": "Move the player left"},
    {"name": "move_right", "description": "Move the player right"},
]

message = self.model.invoke([user_message], functions=functions)

messageのフォーマットは次のようになっていて、additional_kwargs["function_call"]に実行すべき関数の引数と名前が格納されています。
決めきれなかった場合はadditional_kwargsが空となっているので、これによって判断して例外処理を行うこともできます。

{
    content = ''
    additional_kwargs = {
        'function_call': {
            'arguments': '{}',
            'name': 'move_up'
        }
    }
    response_metadata = {
        'token_usage': {
            'completion_tokens': 10,
            'prompt_tokens': 84,
            'total_tokens': 94
        },
        'model_name': 'gpt-3.5-turbo-0125',
        'system_fingerprint': None,
        'finish_reason': 'function_call',
        'logprobs': None
    }
    id = 'run-e20b7e10-6e01-4868-a1ce-b2145ed7e77e-0'
    usage_metadata = {
        'input_tokens': 84,
        'output_tokens': 10,
        'total_tokens': 94
    }
}

関数を実行してクライアント側に返却する

決定された関数を実行し、結果をクライアント側に返却します。
今回用意した関数はメッセージを返すだけのシンプルなものですが、Function callingでは関数に渡すべき引数も取得できるので、さらに複雑な処理を実装することもできます。

# 関数呼び出し用の辞書
self.available_functions = {
    "move_up": self.move_up,
    "move_down": self.move_down,
    "move_left": self.move_left,
    "move_right": self.move_right,
}

# 省略

def move_up(self):
    return "move-up"

def move_down(self):
    return "move-down"

def move_left(self):
    return "move-left"

def move_right(self):
    return "move-right"

# 省略

function_call = message.additional_kwargs.get("function_call")
if function_call:
    # 判断できた場合は関数を呼び出す
    function_name = function_call["name"]
    response = self.available_functions[function_name]()
    return response
else:
    # 判断できない場合はunknownを返す
    return "unknown"

結果を受け取りプレイヤーを動かす

サーバー側からのレスポンスは "move-up"、"move-down"、"move-left"、"move-right"、"unknown" のいずれかとなっていますので、レスポンスに応じてプレイヤーの座標を更新します。

const data = await response.json();
movePlayer(data.message);

// 省略

// レスポンスに応じてプレイヤーを移動させる
function movePlayer(message) {
  if (message === "move-up") {
    player.y -= SETTINGS.MOVE_DISTANCE;
  } else if (message === "move-down") {
    player.y += SETTINGS.MOVE_DISTANCE;
  } else if (message === "move-left") {
    player.x -= SETTINGS.MOVE_DISTANCE;
  } else if (message === "move-right") {
    player.x += SETTINGS.MOVE_DISTANCE;
  } else {
    // 不明なアクションなら3秒考える
    player.setText('🤔');
    setTimeout(() => {
      player.setText('😊');
    }, 3000);
  }
}

まとめ

Phaser3 + Whisper + OpenAI APIを使って、簡単に音声認識で動くブラウザゲームを実装することができました!
モデルの選択や実装次第で、もっと複雑なゲームを音声だけで柔軟に操作することも可能ではないかと思える検証結果でした。

検証に使ったソースコードはGitHubで公開しておりますので、もしご興味がありましたらお試しください! github.com

とらのあな通販開発採用情報

虎の穴ラボでは、一緒とらのあな通販を開発していく仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧ください。
Java経験がある方は、ぜひ通販開発エンジニアへ応募下さい!
https://toranoana-lab.co.jp/job/300toranoana-lab.co.jp