見出し画像

iRacingを音声操作して最強になろう

OpenAI APIを利用して、iRacingのピットコマンドを音声で操作できるPythonプログラムを作成したので共有します。
こんな感じで動作します。



0. はじめに

プログラムを作成するにあたっていくつかの留意事項があります。

  • OpenAI APIは従量課金制です。利用量に応じて料金を支払う必要があります。(多分行っても月100円くらい)

  • 今回はプッシュトゥトークで音声を送信する仕組みにしています。(google gemini 2.0 flashとかだと、リアルタイムで会話できる方法があるみたいなので、それ使っても面白いかも)

  • PythonからiRacingにピットコマンドを送信するのに、pyirsdkを使用します。

1. 環境構築

Pythonコードを動かすために、環境構築が必要なので、さらっと紹介します。

pip install pyirsdk openai SpeechRecognition pygame

2. 音声を文字起こしする機能を作ろう

音声を文字起こしするのに、SpeechRecognitionというライブラリを使用します。プログラムの先頭でインポートしましょう。

import speech_recognition as sr

次に音声を文字起こしする関数を作成します。
recognizer.recognize_googleの引数languageに日本語を指定します。

def speech2text():
    recognizer = sr.Recognizer()
    with sr.Microphone() as source:
        print("話してください")
        audio = recognizer.listen(source)
        print("認識中...")
        text = recognizer.recognize_google(audio, language="ja-JP") #日本語を指定
        print(text)

関数を実行しましょう。

speech2text()

このようになれば成功です。

話してください
認識中...
こんにちは

3. コントローラの設定をしよう

pythonでコントローラを扱うために、pygameを使用します。
使用するコントローラの名前とボタン番号を知っておく必要があるので、チェック用のプログラムを作成しましょう。

import pygame
import time
import json

def check_controller_name():
    pygame.init()
    pygame.joystick.init()
    for i in range(pygame.joystick.get_count()):
        joystick = pygame.joystick.Joystick(i)
        joystick.init()
        print(f"コントローラ : {joystick.get_id()} 名前 : {joystick.get_name()}")

check_controller_name()
        
JOYSTICK_NAME = input("コントローラ名 : ")

def chack_button_number():
    controller_config = {}
    pygame.init()
    pygame.joystick.init()
    for i in range(pygame.joystick.get_count()):
        joystick = pygame.joystick.Joystick(i)
        joystick.init()
        if joystick.get_name() == JOYSTICK_NAME:
            joystick_id = i
            break
        
    if joystick_id is None:
        print(f"コントローラ : {JOYSTICK_NAME} が見つかりません")
        return
    
    joystick = pygame.joystick.Joystick(joystick_id)
    joystick.init()
    
    print("ボタンを押してください")
    
    while True:
        for event in pygame.event.get():
            if event.type == pygame.JOYBUTTONDOWN:
                print(f"コントローラ : {joystick.get_id()} ボタン : {event.button} が押されました")
                controller_config["joystick_name"] = joystick.get_name()
                controller_config["button_number"] = event.button
                return controller_config
        time.sleep(0.01)

controller_config = chack_button_number()
print(controller_config)

with open("controller_config.json", "w") as f:
    json.dump(controller_config, f)

上記のコードを実行すると、接続されているコントローラが表示されます。
使用したいコントローラの名前をコピーしてインプットに貼り付けましょう。
次に使用したいボタンをクリックすると、設定がjsonに保存されます。

4. PushtoTalk機能を作ろう

PushtoTalk機能を作っていく前に、録音開始、終了、中止のタイミングが分かるように、効果音を追加します。

この効果音を追加し、PushtoTalkを実装したのが次のコードになります。

import asyncio 
import speech_recognition as sr 
import pygame
import json

with open("controller_config.json", "r") as f:
    controller_config = json.load(f)
    JOYSTICK_NAME = controller_config["joystick_name"]
    BUTTON_NUMBER = controller_config["button_number"]


class PushToTalk:

    def __init__(self):
        pygame.init()
        pygame.joystick.init()
        pygame.mixer.init()
        
        self.joystick_id = None
        if pygame.joystick.get_count() == 0:
            print("コントローラが接続されていません")
            return
        else:
            for i in range(pygame.joystick.get_count()):
                joystick = pygame.joystick.Joystick(i)
                joystick.init()
                if joystick.get_name() == JOYSTICK_NAME:
                    self.joystick_id = i
                    break
            if self.joystick_id is None:
                print(f"コントローラ : {JOYSTICK_NAME} が見つかりません")
                return
            
            self.joystick = pygame.joystick.Joystick(self.joystick_id)
            self.joystick.init()
            
        self.is_button_pushed = False
        
        self.start_sound = pygame.mixer.Sound("start_sound.wav")
        self.stop_sound = pygame.mixer.Sound("stop_sound.wav")
        self.error_sound = pygame.mixer.Sound("error_sound.wav")

    async def monitor_controller(self):
        while True:
            for event in pygame.event.get():
                if event.type == pygame.JOYBUTTONDOWN:
                    if event.button == BUTTON_NUMBER and not self.is_button_pushed:
                        self.is_button_pushed = True
                        print("ボタンが押されました")
                        asyncio.create_task(self.speech2text())
                elif event.type == pygame.JOYBUTTONUP:
                    if event.button == BUTTON_NUMBER and self.is_button_pushed:
                        self.is_button_pushed = False
                        print("ボタンが離されました")
            await asyncio.sleep(0.01)

    async def speech2text(self):
        try:
            recognizer = sr.Recognizer()
            await asyncio.sleep(0.2) # すぐ離されたら録音しない
            if not self.is_button_pushed:
                self.error_sound.play()
                return
            with sr.Microphone() as source:
                self.start_sound.play()
                print("話してください")
                audio = recognizer.listen(source, timeout=1.5) # 1.5秒何も入力がなかったら認識を中止
                
            await asyncio.sleep(0.01)
            if not self.is_button_pushed:
                self.error_sound.play()
                return # 途中でボタンが離されたら認識を中止
            
            self.stop_sound.play()
            text = recognizer.recognize_google(audio, language="ja-JP")
            print(text)
        except Exception as e:
            self.error_sound.play()
            if self.is_button_pushed:
                print(f"エラーが発生しました: {e}")
        
    async def run(self):
        async with asyncio.TaskGroup() as tg:
            tg.create_task(self.monitor_controller())


if __name__ == "__main__":
    main = PushToTalk()
    try:
        asyncio.run(main.run())
    except KeyboardInterrupt:
        print("終了します")
    except Exception as e:
        print(f"エラーが発生しました: {e}")
        print("終了します")

終了音が鳴るまでボタンを押し続け、話した言葉が表示されれば成功です。


ボタンが押されました
話してください
こんにちは
ボタンが離されました

5. 入力した音声をChatGPTに送信しよう

先ほど作成したコードに、追加でopenaiモジュールをインポートします。

import openai

ChatGPTクラスを新しく作成し、音声を認識したら、send2aiメソッドを実行するようにします。

class ChatGPT:
    def __init__(self):
        self.client = openai.OpenAI(api_key="ここにAPIキーを入力")
        
    async def send2ai(self, message):
        response = self.client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": "あなたは優秀なAIアシスタントです。"},
                {"role": "user", "content": message},
            ],
        )
        
        input_tokens = response.usage.prompt_tokens
        output_tokens = response.usage.completion_tokens
        print(f"インプットトークン: {input_tokens}, アウトプットトークン: {output_tokens}")
        print(response.choices[0].message.content)

class PushToTalk: 
    def __init__(self):
        self.ai = ChatGPT():
~~~~~~~~~~省略~~~~~~~~~~
    async def speech2text(self):
~~~~~~~~~~省略~~~~~~~~~~
            text = recognizer.recognize_google(audio, language="ja-JP")
            print(text)
            asyncio.create_task(self.ai.send2ai(text)) # AIに文字起こししたテキストを送信

ChatGPTからの回答がコンソールに出力されれば成功です。

ボタンが押されました
話してください
こんにちは
インプットトークン: 24, アウトプットトークン: 16
こんにちは!何かお手伝いできることはありますか?
ボタンが離されました
終了します

6. iRacingにピットコマンドを送信しよう

いよいよiRacingを音声認識で操作する部分を作っていきます。

iRacing側の設定

iRacingのチャットコマンドの11から15番目のスロットを以下のように編集してください。

追加するモジュールをインポート

import time
import irsdk
from irsdk import PitCommandMode

FunctionCallingでiRacingにコマンドを送信

FunctionCallingとは、プロンプトに応じてAIが関数を呼び出してくれる機能です。
これを利用し、自分がやりたい操作を関数にして、それをChatGPTに呼び出してもらうようにします。
以下のようにChatGPTクラスを変更します。

SLEEP = 0.1 # 定数を宣言

class ChatGPT:

    functions = [
        {"name": "check_all_tires", "description": "check_all_tires"},
        {"name": "check_front_tires", "description": "check_front_tires"},
        {"name": "check_rear_tires", "description": "check_rear_tires"},
        {"name": "check_left_tires", "description": "check_left_tires"},
        {"name": "check_right_tires", "description": "check_right_tires"},
        {"name": "check_tire", "description": "check_tire_with_pressure, tireはタイヤの位置(lf, rf, lr, rr)、pressureはタイヤの圧力", "parameters": {
            "type": "object", "properties": {"tire": {"type": "string"}, "pressure": {"type": "number"}}}, "required": ["tire"]},
        {"name": "check_fuel", "description": "check_fuel", "parameters": {
            "type": "object", "properties": {"fuel": {"type": "number"}}}},
        {"name": "check_ws", "description": "check_ws"},
        {"name": "check_fr", "description": "check_fr ファストリペアにチェックをつける"},
        {"name": "clear_all_tires", "description": "clear_all_tires_check"},
        {"name": "clear_ws", "description": "clear_ws_check"},
        {"name": "clear_fr", "description": "clear_fr_check"},
        {"name": "clear_fuel", "description": "clear_fuel_check 次回給油しない"},
        {"name": "clear_all", "description": "clear_all_check"},
        {"name": "clear_other_than_fr", "description": "clear_other_than_fr"},
        {"name": "change_tire_compound", "description": "change_tire_compound dry or wet", "parameters": {
            "type": "object", "properties": {"compound": {"type": "string"}}}, "required": ["compound"]},
        {"name": "toggle_autofuel",
         "description": "toggle_autofuel 「切り替えて」「オンにして」「オフにして」など"},
        {"name": "set_fuel_margin", "description": "set_fuel_margin 「増やして」「1周にして」のときはTrue、「減らして」「0周にして」のときはFalse",
         "parameters": {"type": "object", "properties": {"margin": {"type": "boolean"}}}, "required": ["margin"]},
    ]

    def __init__(self):
        self.client = openai.OpenAI(
            api_key="ここにAPIキーを入力")
        self.ir = irsdk.IRSDK()
        self.ir.startup()

    async def send2ai(self, message):
        response = self.client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "system", "content": "あなたは優秀なピットアシスタントです。"},
                {"role": "user", "content": message},
            ],
            functions=self.functions,
            function_call="auto",
        )

        input_tokens = response.usage.prompt_tokens
        output_tokens = response.usage.completion_tokens
        print(f"インプットトークン: {input_tokens}, アウトプットトークン: {output_tokens}")
        print(response.choices[0].message.content)
        if response.choices[0].message.function_call is not None:
            answer = self.execute_function(response.choices[0].message)
            print(answer)
        else:
            print("サポートされていないコマンドです")

    def execute_function(self, message):
        function_name = message.function_call.name
        arguments = json.loads(message.function_call.arguments)
        if function_name == "check_all_tires":
            return self.check_all_tires()
        elif function_name == "check_front_tires":
            return self.check_front_tires()
        elif function_name == "check_rear_tires":
            return self.check_rear_tires()
        elif function_name == "check_left_tires":
            return self.check_left_tires()
        elif function_name == "check_right_tires":
            return self.check_right_tires()
        elif function_name == "check_tire":
            pressure = arguments.get("pressure", 0)
            return self.check_tire(arguments["tire"], pressure)
        elif function_name == "check_fuel":
            fuel = arguments.get("fuel", 0)
            return self.check_fuel(fuel)
        elif function_name == "check_ws":
            return self.check_ws()
        elif function_name == "check_fr":
            return self.check_fr()
        elif function_name == "clear_all_tires":
            return self.clear_all_tires()
        elif function_name == "clear_ws":
            return self.clear_ws()
        elif function_name == "clear_fr":
            return self.clear_fr()
        elif function_name == "clear_fuel":
            return self.clear_fuel()
        elif function_name == "clear_all":
            return self.clear_all()
        elif function_name == "clear_other_than_fr":
            return self.clear_other_than_fr()
        elif function_name == "change_tire_compound":
            return self.change_tire_compound(arguments["compound"])
        elif function_name == "toggle_autofuel":
            return self.toggle_autofuel()
        elif function_name == "set_fuel_margin":
            return self.set_fuel_margin(arguments["margin"])
        else:
            return "サポートされていない関数です"

    def check_all_tires(self):
        self.ir.pit_command(PitCommandMode.lf, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.rf, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.lr, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.rr, 0)
        return "全てのタイヤにチェックをつけました"

    def check_front_tires(self):
        self.ir.pit_command(PitCommandMode.clear_tires, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.lf, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.rf, 0)
        return "フロントタイヤにチェックをつけました"

    def check_rear_tires(self):
        self.ir.pit_command(PitCommandMode.clear_tires, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.lr, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.rr, 0)
        return "リアタイヤにチェックをつけました"

    def check_left_tires(self):
        self.ir.pit_command(PitCommandMode.clear_tires, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.lf, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.lr, 0)
        return "左のタイヤにチェックをつけました"

    def check_right_tires(self):
        self.ir.pit_command(PitCommandMode.clear_tires, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.rf, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.rr, 0)
        return "右のタイヤにチェックをつけました"

    def check_tire(self, tire: str, pressure: float = 0):
        tires = {"lf": PitCommandMode.lf, "rf": PitCommandMode.rf,
                 "lr": PitCommandMode.lr, "rr": PitCommandMode.rr}
        self.ir.pit_command(tires[tire], int(pressure))
        return f"{tire}のタイヤにチェックをつけました"

    def check_fuel(self, fuel: float = 0):
        self.ir.pit_command(PitCommandMode.fuel, int(fuel))
        return f"燃料を{fuel}L追加しました"

    def check_ws(self):
        self.ir.pit_command(PitCommandMode.ws, 0)
        return "ウインドシールドにチェックをつけました"

    def check_fr(self):
        self.ir.pit_command(PitCommandMode.fr, 0)
        return "ファストリペアにチェックをつけました"

    def clear_all_tires(self):
        self.ir.pit_command(PitCommandMode.clear_tires, 0)
        return "全てのタイヤのチェックを外しました"

    def clear_ws(self):
        self.ir.pit_command(PitCommandMode.clear_ws, 0)
        return "ウインドシールドのチェックを外しました"

    def clear_fr(self):
        self.ir.pit_command(PitCommandMode.clear_fr, 0)
        return "ファストリペアのチェックを外しました"

    def clear_fuel(self):
        self.ir.pit_command(PitCommandMode.clear_fuel, 0)
        return "燃料のチェックを外しました"

    def clear_all(self):
        self.ir.pit_command(PitCommandMode.clear, 0)
        return "全てのチェックを外しました"

    def clear_other_than_fr(self):
        self.ir.pit_command(PitCommandMode.clear, 0)
        time.sleep(SLEEP)
        self.ir.pit_command(PitCommandMode.fr, 0)
        return "ファストリペア以外のチェックを外しました"

    def change_tire_compound(self, compound: str):
        tires = {"dry": 10, "wet": 11}
        self.ir.chat_command_macro(macro_num=tires[compound])
        return f"{compound}タイヤに変更しました"

    def toggle_autofuel(self):
        self.ir.chat_command_macro(macro_num=12)
        return "オートフューエルを切り替えました"

    def set_fuel_margin(self, margin: bool):
        margins = {True: [13, '1周'], False: [14, '0周']}
        self.ir.chat_command_macro(macro_num=margins[margin][0])
        return f"オートフューエルのマージンを{margins[margin][1]}にしました"

基本的なピットコマンドは、IRSDK.pit_command()で操作できます。
タイヤのコンパウンド切り替え(ここではドライ⇔ウェットを想定)やオートフューエルの切り替えなどは、このメソッドで操作できません。
なので、先ほど設定したチャットコマンドを呼び出せるよう、IRSDK.chat_command_macro()メソッドを使用しています。

7. さいごに

以上が、iRacingを音声認識で操作できるプログラムの作り方になります。
プロンプトの書き方や、FunctionCallingの指定方法など、改善できる部分が沢山あると思います。
また、ピットコマンドに限らず、リプレイカメラの操作などがirsdkからの入力に対応しているので、実装してみても面白いかもしれません。

いいなと思ったら応援しよう!