UnityでOpenAIのrealtime apiで入力した音声の文字起こしを取得する方法

初めに

以下の記事でUnityでOpenAIのrealtime apiを使って音声のやり取りを行いました。今回は以下のやり取りをする際に ユーザーが入力をした音声の文字起こしを取得したい場合の設定についてです。

ayousanz.hatenadiary.jp

開発環境

  • Unity 2022.3.34f1

実装

以下のドキュメントを見ると sessionの中の input_audio_transcription が設定できることがわかります。

{
    "event_id": "event_1234",
    "type": "session.created",
    "session": {
        "id": "sess_001",
        "object": "realtime.session",
        "model": "gpt-4o-realtime-preview-2024-10-01",
        "modalities": ["text", "audio"],
        "instructions": "",
        "voice": "alloy",
        "input_audio_format": "pcm16",
        "output_audio_format": "pcm16",
        "input_audio_transcription": null,
        "turn_detection": {
            "type": "server_vad",
            "threshold": 0.5,
            "prefix_padding_ms": 300,
            "silence_duration_ms": 200
        },
        "tools": [],
        "tool_choice": "auto",
        "temperature": 0.8,
        "max_response_output_tokens": null
    }
}

詳しくドキュメントを見ると以下のように記載があります。

input_audio_transcription object

Configuration for input audio transcription, defaults to off and can be set to null to turn off once on. Input audio transcription is not native to the model, since the model consumes audio directly. Transcription runs asynchronously through Whisper and should be treated as rough guidance rather than the representation understood by the model.

Hide properties model string

The model to use for transcription, whisper-1 is the only currently supported model.

platform.openai.com

そこでセッションを更新するタイミングで input_audio_transcription の中を設定します。

Unity側のコードでは以下のように設定をします。

        var sessionUpdateMessage = new
        {
            type = "session.update",
            session = new
            {
                input_audio_transcription = new
                {
                    model = "whisper-1"
                }
            }
        };

        string jsonMessage = JsonConvert.SerializeObject(sessionUpdateMessage);
        _connection.AddOutgoingMessage(jsonMessage);

sbintuitions/sarashina2-8x70bを試す

初めに

SB Intuitions株式会社から現時点で日本語の性能が一番高い(らしい)モデルが出たので、動かしていきます。

開発環境

準備

以下をインストールします

pip install torch --index-url https://download.pytorch.org/whl/nightly/cu121
pip install transformers==4.46.2 bitsandbytes==0.44.1 accelerate==1.1.1 sentencepiece==0.2.0

推論

以下で動かします。VRAMは限りがあるので、今回は8bit量子化を行い推論を行います

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

# モデル名
model_name = "sbintuitions/sarashina2-8x70b"

# 8ビット量子化のための設定
bnb_config = BitsAndBytesConfig(
    load_in_8bit=True,
    llm_int8_threshold=6.0,  # 16bit floatingに保持するしきい値
    llm_int8_skip_modules=None  # 量子化をスキップするモジュールを指定可能
)

# 8ビット量子化でモデルをロード
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",   # 複数GPUへの自動割り当て
    torch_dtype=torch.float16  # その他パラメータにfp16(半精度浮動小数点数)を使用
)


# テスト用の入力シーケンス
prompt = "まどマギで一番可愛いキャラクターは、"

# トークナイザーのロード
tokenizer = AutoTokenizer.from_pretrained(model_name, add_eos_token=False)
inputs = tokenizer(prompt, return_tensors="pt")

# GPUがある場合にcudaを使用
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# トークナイズ(入力をトークンに変換)
inputs = tokenizer(prompt, return_tensors="pt").to(device)

# デバイスに自動的に対応させた状態でモデル推論
outputs = model.generate(inputs["input_ids"], max_new_tokens=100)

# 出力のデコード(トークンをテキストに変換)
output_text = tokenizer.decode(outputs[0], skip_special_tokens=False)

# 結果を表示
print(output_text)

結果は以下の通りです

まどマギで一番可愛いキャラクターは、まどかでもほむらでもマミさんでも杏子でもさやかでもなく、キュゥべえです。
キュゥべえは、魔法少女のマスコットキャラクターです。
キュゥべえは、魔法少女のマスコットキャラクターとして、魔法少女の勧誘やサポートをしています。
キュゥべえは、魔法少女のマスコットキャラクターとして、魔法少女の勧誘やサポートをしていますが、その目的は、魔法少女の魂をソウルジェムに変えることです。
キュゥべえは、魔法少女のマスコットキャラクターとして、魔法少女の勧誘やサポートをしていますが、

使用VRAM

4bit量子化で290GB程度、8bit量子化で590GB程度でした

microsoft/BitNetをWindowsで動かす

初めに

transformers v4.46.0にBitNetが追加されたみたいなので、今後加速しそうなBitNetの本家を触ってみます

github.com

MicrosoftのBitNetは以下です

github.com

開発環境

環境構築

ReadMeの通りに行っていきます

git clone --recursive https://github.com/microsoft/BitNet.git
cd BitNet
conda create -n bitnet-cpp python=3.9
conda activate bitnet-cpp

pip install -r requirements.txt

python setup_env.py --hf-repo HF1BitLLM/Llama3-8B-1.58-100B-tokens -q i2_s

実行

以下でLlama3-8B-1.58-100B-tokensを実行できます

python run_inference.py -m models/Llama3-8B-1.58-100B-tokens/ggml-model-i2_s.gguf -p "Daniel went back to the the the garden. Mary travelled to the kitchen. Sandra journeyed to the kitchen. Sandra went to the hallway. John went to the bedroom. Mary went back to the garden. Where is Mary?\nAnswer:" -n 6 -temp 0

まどマギプロンプトの結果は以下です

プロンプト

python run_inference.py -m models/Llama3-8B-1.58-100B-tokens/ggml-model-i2_s.gguf -p "Who is the cutest character in Madoka Magica?\nAnswer:" -n 100 -temp 0

結果

Who is the cutest character in Madoka Magica?
Answer: Madoka Magica is a manga series written by Yuki Kadowa and illustrated by Yuki Kadowa. The series follows the story of a young girl named Madoka Magica, who is a witch and the daughter of the Madoka family. Madoka Magica is a cute and innocent character, and her character development is one of the series’ most notable aspects.
What is the name of the main character in Madoka Magica?
Answer: Madoka Magica is the main

実行速度は以下の画像の通りです

WindowsでGPT-SoVITSのローカルサーバーを立てる

初めに

いまさらですが、GPT-SoVITSのローカル推論を試していきます。今回はサーバー化したいため、fastAPIのサーバーを立てます

github.com

以下でuv 環境で記事の通りに以下の対応を行ったリポジトリを公開しています

  • シンプルなクライアントコードを作成
  • サーバーコードの修正
  • uv環境の構築

github.com

開発環境

準備

環境作成とライブラリのインストール

WindowsPowerShellの場合は、以下でUTF-8 エンコーディングが上手くできるように設定をします

$env:PYTHONIOENCODING = "utf-8"

次にuvの環境を作成します

uv init python=3.9
uv

必要なライブラリをインストールします

uv add -r requirements.txt
uv add ffmpeg-python 
uv add pyopenjtalk

この際にインストールされるtorchがcpuになっているので、そのまま動かす場合は GPT-SoVITS\GPT_SoVITS\configs\tts_infer.yaml の customのdeviceを以下のように cpu に変更します。またcpuでの実行の場合は、is_half をfalseにします

custom:
  bert_base_path: GPT_SoVITS/pretrained_models/chinese-roberta-wwm-ext-large
  cnhuhbert_base_path: GPT_SoVITS/pretrained_models/chinese-hubert-base
  device: cpu
  is_half: false
  t2s_weights_path: GPT_SoVITS/pretrained_models/gsv-v2final-pretrained/s1bert25hz-5kh-longer-epoch=12-step=369668.ckpt
  version: v2
  vits_weights_path: GPT_SoVITS/pretrained_models/gsv-v2final-pretrained/s2G2333k.pth

GPU(CUDA)で処理をしたい場合は、以下でcuda版をインストールします

uv pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 --force-reinstall

またこの際に tts_infer.yaml の customの設定をいかに変更します

  device: cuda
  is_half: true

各モデルの配置

huggingface.co

公式のモデルをcloneもしくはダウンロードしてきて、以下の画像のようにモデルを配置します

GPT_SoVITS/
├── pretrained_models/
    ├── chinese-hubert-base/
    │   ├── config.json
    │   ├── preprocessor_config.json
    │   └── pytorch_model.bin
    ├── chinese-roberta-wwm-ext-large/
    │   ├── config.json
    │   ├── tokenizer.json
    │   └── pytorch_model.bin
    ├── gsv-v2final-pretrained/
    │   ├── s1bert25hz-5kh-longer-epoch=12-step=369668.ckpt
    │   └── s2G2333k.pth
    └── .gitignore

サーバーコードの修正

api_v2.py に以下を追加します

import ffmpeg

ローカルサーバーの起動

以下のコマンドでローカルサーバーを立てることができます

uv run api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/tts_infer.yaml

http://127.0.0.1:9880/docs にアクセスすると Swagger UIが表示されます

クライアントからサーバーを実行

今回はシンプルなクライアントコードを書いて実行をします

import requests

def tts_get():
    # GETリクエストのパラメータを設定
    params = {
        'text': 'こんにちは',
        'text_lang': 'ja',
        'ref_audio_path': 'ref_audio.wav',
        'prompt_text': 'また、東寺のように、五大明王と呼ばれる、主要な明王の中央に配されることも多い。',
        'prompt_lang': 'ja',
        'media_type': 'wav',
        'streaming_mode': 'false'
    }

    # GETリクエストを送信
    response = requests.get('http://127.0.0.1:9880/tts', params=params)

    # レスポンスの処理
    if response.status_code == 200:
        # 音声データをファイルに保存
        with open('output_get.wav', 'wb') as f:
            f.write(response.content)
        print('音声ファイルをoutput_get.wavに保存しました。')
    else:
        print(f"エラー {response.status_code}: {response.text}")

if __name__ == '__main__':
    tts_get()

実行することで以下の結果および音声ファイルが生成されます

uv run .\client.py
音声ファイルをoutput_get.wavに保存しました。

エラー対応

pyopenjtalkのインストールがうまくいかない場合

以下のライブラリを代用することでインストールが上手くいくことがあります。

github.com

以下でインストールすることができます

pip install pyopenjtalk-plus

torch関連が上手くいかない

個別に以下でインストールを行ってからほかのライブラリをインストールします。

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121

特定のversionをインストールする場合は、以下のようにします

pip install torch==2.5.0 torchvision==0.20.0 torchaudio==2.5.0 --index-url https://download.pytorch.org/whl/cu121

pytorch.org

CMakeがないと言われた場合

以下からダウンロードしてインストールを行います

cmake.org

UnityでOpenAIのrealtime apiのStream user audioを動かす

初めに

OpenAIからrealtime apiが出たので,Unityでも動かしてみます

実際に動かせるサンプルプロジェクトは以下で公開しています

github.com

開発環境

実装の方針

まずは実際に動かすために公式ドキュメントを眺めます

platform.openai.com

The Realtime API is a stateful, event-based API that communicates over a WebSocket. The WebSocket connection requires the following parameters:

URL: wss://api.openai.com/v1/realtime

Query Parameters: ?model=gpt-4o-realtime-preview-2024-10-01

Headers:

Authorization: Bearer YOUR_API_KEY

OpenAI-Beta: realtime=v1

と記載があるため,Unityで WebSoketを扱う必要があります。またHeaderを明示的に登録する必要があります。

今回は Headerおよびdllなどめんどくいさいことをしたくなかったので,以下のライブラリを使ってみます

github.com

以下で実際に触ってみた記事も書いていますので,シンプルなコードは以下を見てください

ayousanz.hatenadiary.jp

音声フォーマットについて

realtime apiは以下のaudio formatに沿って実装する必要があります

Audio formats Today, the Realtime API supports two formats:

raw 16 bit PCM audio at 24kHz, 1 channel, little-endian G.711 at 8kHz (both u-law and a-law) We will be working to add support for more audio codecs soon.

Audio must be base64 encoded chunks of audio frames.

そのため,データを送る前に以下のようにPCM16に変換をします

    private static byte[] FloatToPCM16(float[] floatData)
    {
        int length = floatData.Length;
        byte[] bytesData = new byte[length * sizeof(short)];

        for (int i = 0; i < length; i++)
        {
            float sample = floatData[i];
            if (sample < -1.0f) sample = -1.0f;
            if (sample > 1.0f) sample = 1.0f;

            short value = (short)(sample * short.MaxValue);
            bytesData[i * 2] = (byte)(value & 0x00ff);
            bytesData[i * 2 + 1] = (byte)((value & 0xff00) >> 8);
        }

        return bytesData;
    }

イベントタイプの一覧

このアプリケーションでは、OpenAIのRealtime APIから受信するさまざまなイベントを処理します。以下に各イベントタイプとその内容を説明します。

session.created

  • イベントタイプ: "session.created"
  • 内容: サーバーが新しいセッションを作成したことを示すイベント。このイベントにはセッションIDが含まれており、クライアント側で保存して以降の通信で使用します。

response.created

  • イベントタイプ: "response.created"
  • 内容: サーバーがレスポンスの生成を開始したことを示すイベント。

rate_limits.updated

  • イベントタイプ: "rate_limits.updated"
  • 内容: レート制限に関する情報が更新されたことを示すイベント。必要に応じて、レート制限の情報を取得して使用します。

conversation.item.created

  • イベントタイプ: "conversation.item.created"
  • 内容: 会話内に新しいアイテム(メッセージや関数呼び出しなど)が追加されたことを示すイベント。

response.output_item.added

  • イベントタイプ: "response.output_item.added"
  • 内容: レスポンスに新しい出力アイテムが追加されたことを示すイベント。

response.output_item.done

  • イベントタイプ: "response.output_item.done"
  • 内容: 出力アイテムの生成が完了したことを示すイベント。

response.content_part.added

  • イベントタイプ: "response.content_part.added"
  • 内容: レスポンスのコンテンツの一部が追加されたことを示すイベント。

response.content_part.done

  • イベントタイプ: "response.content_part.done"
  • 内容: レスポンスのコンテンツの一部の生成が完了したことを示すイベント。

response.text.delta

  • イベントタイプ: "response.text.delta"
  • 内容: レスポンスのテキストの増分データが到着したことを示すイベント。テキストをリアルタイムで更新する際に使用します。

response.text.done

  • イベントタイプ: "response.text.done"
  • 内容: レスポンスのテキストの生成が完了したことを示すイベント。最終的なテキストデータが含まれます。

response.audio_transcript.delta

  • イベントタイプ: "response.audio_transcript.delta"
  • 内容: レスポンスの音声の転写(テキスト化)の増分データが到着したことを示すイベント。

response.audio_transcript.done

  • イベントタイプ: "response.audio_transcript.done"
  • 内容: レスポンスの音声の転写が完了したことを示すイベント。最終的な転写テキストが含まれます。

response.audio.delta

  • イベントタイプ: "response.audio.delta"
  • 内容: レスポンスの音声データの増分が到着したことを示すイベント。音声データをバッファに蓄積します。

response.audio.done

  • イベントタイプ: "response.audio.done"
  • 内容: レスポンスの音声データの送信が完了したことを示すイベント。バッファに蓄積された音声データを再生します。

response.done

  • イベントタイプ: "response.done"
  • 内容: レスポンスの全ての処理が完了したことを示すイベント。

input_audio_buffer.speech_started

  • イベントタイプ: "input_audio_buffer.speech_started"
  • 内容: クライアントからの音声入力が開始されたことを示すイベント。

input_audio_buffer.speech_stopped

  • イベントタイプ: "input_audio_buffer.speech_stopped"
  • 内容: クライアントからの音声入力が停止したことを示すイベント。

input_audio_buffer.committed

  • イベントタイプ: "input_audio_buffer.committed"
  • 内容: クライアントからの音声入力がサーバーにコミットされたことを示すイベント。これにより、サーバー側で音声が処理されます。

error

  • イベントタイプ: "error"
  • 内容: エラーが発生したことを示すイベント。エラーメッセージを取得してデバッグやエラーハンドリングに使用します。

全体のコード

実際の全体のコードは以下になります

using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using MikeSchweitzer.WebSocket;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using TMPro;
using UnityEngine;

/// <summary>
/// OpenAIのrealtime apiを使用して、音声データを送信し、テキストと音声を受信するサンプル
/// </summary>
public class OpenAIRealtimeAudio : MonoBehaviour
{
    /// <summary>
    /// OpenAI APIキー
    /// </summary>
    [SerializeField] private string apiKey = "YOUR_OPENAI_API_KEY";

    /// <summary>
    /// 使用するモデルの名前
    /// </summary>
    private const string ModelName = "gpt-4o-realtime-preview-2024-10-01";


    /// <summary>
    /// テキストを表示するTextMeshProUGUI
    /// </summary>
    [SerializeField] private TextMeshProUGUI assistantText;

    /// <summary>
    /// 音声を再生するAudioSource
    /// </summary>
    private AudioSource _audioSource;

    /// <summary>
    /// WebSocketConnectionのインスタンス
    /// </summary>
    private WebSocketConnection _connection;

    /// <summary>
    /// 使用するマイクの名前
    /// </summary>
    private string _microphone;

    /// <summary>
    /// マイクから取得した音声データを格納するAudioClip
    /// </summary>
    private AudioClip _audioClip;

    /// <summary>
    /// マイクから取得した最後のサンプル位置
    /// </summary>
    private int _lastSamplePosition = 0;

    /// <summary>
    /// 接続状態を示すフラグ
    /// </summary>
    private bool _isConnected = false; // 接続状態を示すフラグ

    /// <summary>
    /// 音声データのバッファ
    /// </summary>
    private readonly List<byte> _audioBuffer = new List<byte>();

    private void Awake()
    {
        _audioSource = gameObject.AddComponent<AudioSource>();
        _connection = gameObject.AddComponent<WebSocketConnection>();
    }

    /// <summary>
    /// オブジェクトが有効になったときに呼び出される
    /// </summary>
    private void Start()
    {
        // マイクの初期化
        if (Microphone.devices.Length > 0)
        {
            _microphone = Microphone.devices[0];
            _audioClip = Microphone.Start(_microphone, true, 1, 24000);
        }
        else
        {
            Debug.LogError("マイクが接続されていません");
            return;
        }

        // イベントの購読
        _connection.MessageReceived += OnMessageReceived;
        _connection.ErrorMessageReceived += OnErrorMessageReceived;

        // 接続開始
        ConnectToRealtimeAPI().Forget();
    }

    /// <summary>
    ///  Realtime APIに接続
    /// </summary>
    private async UniTaskVoid ConnectToRealtimeAPI()
    {
        string url = $"wss://api.openai.com/v1/realtime?model={ModelName}";

        // ヘッダーの設定
        var headers = new Dictionary<string, string>
        {
            { "Authorization", $"Bearer {apiKey}" },
            { "OpenAI-Beta", "realtime=v1" }
        };

        // 接続設定の作成
        _connection.DesiredConfig = new WebSocketConfig
        {
            Url = url,
            Headers = headers,
            MaxReceiveBytes = 1024 * 1024 * 5, // 5MBに設定(必要に応じて調整)
            MaxSendBytes = 1024 * 1024 * 5, // 5MBに設定(必要に応じて調整)
        };

        _connection.Connect();

        // 接続が確立されるまで待機
        await UniTask.WaitUntil(() => _connection.State == WebSocketState.Connected);

        Debug.Log("Connected to Realtime API");

        _isConnected = true; // 接続フラグを設定
    }

    private void Update()
    {
        // 接続が確立されるまで音声データの送信を停止
        if (!_isConnected)
        {
            return;
        }

        // マイクから音声データを取得して送信
        if (Microphone.IsRecording(_microphone))
        {
            int currentPosition = Microphone.GetPosition(_microphone);

            if (currentPosition < _lastSamplePosition)
            {
                // ループした場合
                _lastSamplePosition = 0;
            }

            int sampleLength = currentPosition - _lastSamplePosition;

            if (sampleLength > 0)
            {
                float[] samples = new float[sampleLength];
                _audioClip.GetData(samples, _lastSamplePosition);

                // 更新
                _lastSamplePosition = currentPosition;

                // 音声データを送信
                SendAudioData(samples);
            }
        }
    }

    /// <summary>
    /// 音声データを送信
    /// </summary>
    /// <param name="audioData"></param>
    private void SendAudioData(float[] audioData)
    {
        if (_connection.State != WebSocketState.Connected)
        {
            // 接続が確立されていない場合は送信しない
            return;
        }

        byte[] pcmData = FloatToPCM16(audioData);
        string base64Audio = Convert.ToBase64String(pcmData);

        var eventMessage = new
        {
            type = "input_audio_buffer.append",
            audio = base64Audio
        };

        string jsonMessage = JsonConvert.SerializeObject(eventMessage);
        _connection.AddOutgoingMessage(jsonMessage);
    }

    /// <summary>
    /// メッセージを受信
    /// </summary>
    /// <param name="connection"></param>
    /// <param name="message"></param>
    private void OnMessageReceived(WebSocketConnection connection, WebSocketMessage message)
    {
        // 非メインスレッドから呼び出される可能性があるため、メインスレッドで処理を行う
        UniTask.Post(() => ProcessMessage(message));
    }

    /// <summary>
    /// エラーメッセージを受信
    /// </summary>
    /// <param name="connection"></param>
    /// <param name="errorMessage"></param>
    private void OnErrorMessageReceived(WebSocketConnection connection, string errorMessage)
    {
        // エラーメッセージをメインスレッドでログ出力
        UniTask.Post(() => Debug.LogError($"WebSocket Error: {errorMessage}"));
    }

    /// <summary>
    /// メッセージを処理
    /// </summary>
    /// <param name="message"></param>
    /// <summary>
    /// メッセージを処理
    /// </summary>
    /// <param name="message"></param>
    private void ProcessMessage(WebSocketMessage message)
    {
        // メッセージをパース
        JObject json;
        try
        {
            json = JObject.Parse(message.String);
        }
        catch (Exception ex)
        {
            Debug.LogError($"JSONのパースに失敗しました: {ex.Message}");
            return;
        }

        string messageType = (string)json["type"];

        switch (messageType)
        {
            case "session.created":
            {
                Debug.Log("Session created");
                break;
            }
            case "response.created":
            {
                Debug.Log("Response created");
                break;
            }
            case "rate_limits.updated":
            {
                Debug.Log("Rate limits updated");
                // 必要であれば rate_limits 情報を取得して使用します
                break;
            }
            case "conversation.item.created":
            {
                Debug.Log("Conversation item created");
                // 必要であれば item 情報を取得して使用します
                break;
            }
            case "response.output_item.added":
            {
                Debug.Log("Response output item added");
                // 必要であれば output_item 情報を取得して使用します
                break;
            }
            case "response.output_item.done":
            {
                Debug.Log("Response output item done");
                break;
            }
            case "response.content_part.added":
            {
                Debug.Log("Response content part added");
                break;
            }
            case "response.content_part.done":
            {
                Debug.Log("Response content part done");
                break;
            }
            case "response.text.delta":
            {
                // テキストを更新
                assistantText.text += (string)json["delta"];

                break;
            }
            case "response.text.done":
            {
                assistantText.text = (string)json["text"];
                Debug.Log($"Assistant says: {(string)json["text"]}");

                break;
            }
            case "response.audio_transcript.delta":
            {
                // 音声の転写の増分を取得
                var deltaText = (string)json["delta"];
                assistantText.text += deltaText;


                break;
            }
            case "response.audio_transcript.done":
            {
                var transcript = (string)json["text"];
                assistantText.text = transcript;

                Debug.Log($"Audio transcript done: {transcript}");
                break;
            }
            case "response.audio.delta":
            {
                // 音声の増分を取得
                string deltaBase64 = (string)json["delta"];
                byte[] deltaBytes = Convert.FromBase64String(deltaBase64);
                _audioBuffer.AddRange(deltaBytes);
                break;
            }
            case "response.audio.done":
            {
                // 音声の最終データが到着
                // バッファに溜めた音声データを再生
                PlayAudioFromBytes(_audioBuffer.ToArray());

                // バッファをクリア
                _audioBuffer.Clear();
                break;
            }
            case "response.done":
            {
                Debug.Log("Response done");
                break;
            }
            case "input_audio_buffer.speech_started":
            {
                Debug.Log("Speech started");
                break;
            }
            case "input_audio_buffer.speech_stopped":
            {
                Debug.Log("Speech stopped");
                break;
            }
            case "input_audio_buffer.committed":
            {
                Debug.Log("Input audio buffer committed");
                break;
            }
            case "error":
            {
                string errorMessage = (string)json["error"]?["message"];
                Debug.LogError($"サーバーからのエラー: {errorMessage}");
                break;
            }
            default:
            {
                Debug.LogWarning($"未処理のイベントタイプ: {messageType}");
                break;
            }
        }
    }

    /// <summary>
    /// 音声データを再生
    /// </summary>
    /// <param name="audioBytes"></param>
    private void PlayAudioFromBytes(byte[] audioBytes)
    {
        if (audioBytes == null || audioBytes.Length == 0)
            return;

        float[] floatData = PCM16ToFloat(audioBytes);

        AudioClip clip = AudioClip.Create("Response", floatData.Length, 1, 24000, false);
        clip.SetData(floatData, 0);
        _audioSource.clip = clip;
        _audioSource.Play();
    }

    /// <summary>
    /// float配列をPCM16に変換
    /// </summary>
    /// <param name="floatData"></param>
    /// <returns></returns>
    private static byte[] FloatToPCM16(float[] floatData)
    {
        int length = floatData.Length;
        byte[] bytesData = new byte[length * sizeof(short)];

        for (int i = 0; i < length; i++)
        {
            float sample = floatData[i];
            if (sample < -1.0f) sample = -1.0f;
            if (sample > 1.0f) sample = 1.0f;

            short value = (short)(sample * short.MaxValue);
            bytesData[i * 2] = (byte)(value & 0x00ff);
            bytesData[i * 2 + 1] = (byte)((value & 0xff00) >> 8);
        }

        return bytesData;
    }

    /// <summary>
    /// PCM16をfloat配列に変換
    /// </summary>
    /// <param name="pcmData"></param>
    /// <returns></returns>
    private static float[] PCM16ToFloat(byte[] pcmData)
    {
        int length = pcmData.Length / 2;
        float[] floatData = new float[length];

        for (int i = 0; i < length; i++)
        {
            short value = BitConverter.ToInt16(pcmData, i * 2);
            floatData[i] = value / (float)short.MaxValue;
        }

        return floatData;
    }

    /// <summary>
    /// 初期化メッセージを送信
    /// </summary>
    private void SendInitializationMessage()
    {
        if (_connection.State != WebSocketState.Connected)
        {
            // 接続が確立されていない場合は送信しない
            return;
        }

        var eventMessage = new
        {
            type = "session.update",
            config = new
            {
                turn_type = "no_turn_detection"
            }
        };

        string jsonMessage = JsonConvert.SerializeObject(eventMessage);
        _connection.AddOutgoingMessage(jsonMessage);

        var responseMessage = new
        {
            type = "response.create",
            response = new
            {
                modalities = new[] { "text", "audio" },
                instructions = "あなたはフレンドリーなAIアシスタントです。"
            }
        };

        string responseJson = JsonConvert.SerializeObject(responseMessage);
        _connection.AddOutgoingMessage(responseJson);
    }

    /// <summary>
    ///  オブジェクトが破棄されたときに呼び出される
    /// </summary>
    private void OnDestroy()
    {
        // イベントの購読解除
        if (_connection != null)
        {
            _connection.MessageReceived -= OnMessageReceived;
            _connection.ErrorMessageReceived -= OnErrorMessageReceived;
            _connection.Disconnect();
        }
    }
}

unity-websocketを使ってUnityでWebSoketを通信を行う

初めに

UnityでWebSoketを使って通信をするのはいろいろ大変なので、どのライブラリを使おうかと調べていましたが、以下のライブラリを見つけたので動かしてみます

github.com

サーバーのコードは以下のリポジトリで公開しています

github.com

開発環境

準備

Python側で以下のライブラリを使用するためインストールを行います

pip install websockets

簡単な接続確認

まずは 接続確認から行っていきます

Unity

using UnityEngine;
using System.Collections;
using MikeSchweitzer.WebSocket;

public class WebSocketTester : MonoBehaviour
{
    public WebSocketConnection _connection;
    private string _url = "ws://localhost:8765";  // Pythonサーバーのアドレス

    private void Start()
    {
        // WebSocketの初期化と接続
        _connection = gameObject.AddComponent<WebSocketConnection>();
        _connection.DesiredConfig = new WebSocketConfig
        {
            Url = _url
        };

        _connection.Connect();

        // 接続状態の変更イベント
        _connection.StateChanged += OnStateChanged;

        // メッセージ受信イベント
        _connection.MessageReceived += OnMessageReceived;
        
        // エラーメッセージ
        _connection.ErrorMessageReceived += OnErrorMessageReceived;
    }

    private void OnStateChanged(WebSocketConnection connection, WebSocketState oldState, WebSocketState newState)
    {
        Debug.Log($"WebSocket state changed from {oldState} to {newState}");

        // 接続が確立された場合
        if (newState == WebSocketState.Connected)
        {
            // サーバーへメッセージを送信
            SendMessageToServer("Hello from Unity");
        }
    }

    private void OnMessageReceived(WebSocketConnection connection, WebSocketMessage message)
    {
        Debug.Log($"Message received from server: {message.String}");
    }

    private void OnErrorMessageReceived(WebSocketConnection connection, string errorMessage)
    {
        Debug.LogError($"WebSocket error: {errorMessage}");
    }

    private void SendMessageToServer(string message)
    {
        if (_connection != null && _connection.State == WebSocketState.Connected)
        {
            _connection.AddOutgoingMessage(message);
            Debug.Log($"Message sent to server: {message}");
        }
    }

    private void OnDestroy()
    {
        if (_connection != null)
        {
            _connection.Disconnect();
            _connection = null;
        }
    }
}

Python

import asyncio
import websockets

async def echo(websocket, path):
    async for message in websocket:
        print(f"Received message: {message}")
        await websocket.send(f"Echo: {message}")

start_server = websockets.serve(echo, "localhost", 8765)

asyncio.get_event_loop().run_until_complete(start_server)
print("WebSocket server started on ws://localhost:8765")
asyncio.get_event_loop().run_forever()

実行をすると以下のようなログが表示されます

クライアントから定期的にメッセージを送信

次にクライアントから定期的にメッセージを送るような少し複雑な?ことをします (サーバーのコードは同じものです)

using System;
using UnityEngine;
using Cysharp.Threading.Tasks;
using System.Threading;
using MikeSchweitzer.WebSocket;
using Random = UnityEngine.Random;

public class WebSoketTest : MonoBehaviour
{
    public WebSocketConnection _connection;
    private string _url = "ws://localhost:8765";  // Pythonサーバーのアドレス
    private bool _shouldReconnect = true;
    private CancellationTokenSource _cts;

    private void Start()
    {
        _cts = new CancellationTokenSource();

        // WebSocketの初期化と接続
        _connection = gameObject.AddComponent<WebSocketConnection>();
        _connection.DesiredConfig = new WebSocketConfig
        {
            Url = _url
        };

        _connection.Connect();

        // 接続状態の変更イベント
        _connection.StateChanged += OnStateChanged;

        // メッセージ受信イベント
        _connection.MessageReceived += OnMessageReceived;
        
        // エラーメッセージ
        _connection.ErrorMessageReceived += OnErrorMessageReceived;

        // 定期的なメッセージ送信を非同期タスクで実行
        SendMessagesPeriodically(_cts.Token).Forget();
    }

    private void OnStateChanged(WebSocketConnection connection, WebSocketState oldState, WebSocketState newState)
    {
        Debug.Log($"WebSocket state changed from {oldState} to {newState}");

        // 再接続の試み
        if (newState == WebSocketState.Disconnected && _shouldReconnect)
        {
            Reconnect().Forget();
        }
    }

    private void OnMessageReceived(WebSocketConnection connection, WebSocketMessage message)
    {
        Debug.Log($"Message received from server: {message.String}");
    }

    private void OnErrorMessageReceived(WebSocketConnection connection, string errorMessage)
    {
        Debug.LogError($"WebSocket error: {errorMessage}");
    }

    private async UniTaskVoid SendMessagesPeriodically(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            if (_connection != null && _connection.State == WebSocketState.Connected)
            {
                var message = "Random message: " + Random.Range(0, 1000);
                _connection.AddOutgoingMessage(message);
                Debug.Log($"Message sent to server: {message}");
            }

            // 送信間隔を待機
            await UniTask.Delay(TimeSpan.FromSeconds(5), cancellationToken: cancellationToken);
        }
    }

    private async UniTaskVoid Reconnect()
    {
        Debug.Log("Attempting to reconnect...");
        await UniTask.Delay(TimeSpan.FromSeconds(5));

        if (_connection != null && _connection.State != WebSocketState.Connected)
        {
            _connection.Connect();
        }
    }

    private void OnDestroy()
    {
        _cts.Cancel();
        _cts.Dispose();

        _shouldReconnect = false;

        if (_connection != null)
        {
            _connection.Disconnect();
            _connection = null;
        }
    }
}

ここでは以下のようなメッセージが表示されます

promptttsppで合成音声を試す(Winodows)

初めに

新しくttsのライブラリが出たので触ってみます

論文の中では日本語の音声合成にも触れられていましたが、デモ版では日本語はできないみたいです

環境

  • WIndows 11
  • anaconda
  • RTX 4070 Ti Super

準備

公式のReadMeの通りに進めていきます

conda create -n py38_prompt python=3.8 numpy scipy scikit-learn numba cython pandas tqdm
conda activate py38_prompt
pip install "torch==1.11.0+cu113" "torchvision==0.12.0+cu113" "torchaudio==0.11.0" --extra-index-url https://download.pytorch.org/whl/cu113
pip install -e .

また 以下のhfから事前学習モデルをダウンロードして以下のように配置します

huggingface.co

egs\proposed\bin\conf\demo.yaml の 設定を書き換えます

model_ckpt_path: ./pretrained_model/checkpoint/proposed/last.ckpt
vocoder_ckpt_path: ./pretrained_model/checkpoint/bigvgan_f0_full/last.ckpt
mel_stats_file: ./pretrained_model/checkpoint/pretrained_model_checkpoint_stats.yaml

実行

以下で動かすことできます

python app.py

推論時間は1.7s程度でした

以下の箇所で計測しています

    @torch.no_grad()
    def onclick_synthesis(content_prompt, style_prompt=None, reference_mel=None):
        start_time = time.perf_counter()
        assert style_prompt is not None or reference_mel is not None
        device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        phonemes = g2p(content_prompt)
        phonemes = [p if p not in [",", "."] else "sil" for p in phonemes]
        phonemes = [p for p in phonemes if p in symbols]
        phoneme_ids = text_to_sequence(" ".join(phonemes))
        phoneme_ids = torch.LongTensor(phoneme_ids)[None, :].to(device)
        if style_prompt is not None:
            dec, log_cf0, vuv = model.infer(
                phoneme_ids,
                style_prompt=style_prompt,
                use_max=True,
                noise_scale=0.5,
                return_f0=True,
            )
        else:
            reference_mel = (reference_mel - mel_stats["mean"]) / mel_stats["std"]
            reference_mel = reference_mel.to(device)
            dec, log_cf0, vuv = model.infer(
                phoneme_ids,
                reference_mel=reference_mel,
                use_max=True,
                noise_scale=0.5,
                return_f0=True,
            )
        modfs = int(1.0 / (10 * 0.001))
        log_cf0 = lowpass_filter(log_cf0, modfs, cutoff=20)
        f0 = log_cf0.exp()
        f0[vuv < 0.5] = 0
        dec = dec * mel_stats["std"] + mel_stats["mean"]
        wav = vocoder(dec, f0).squeeze(1).cpu()
        
        # 終了時間を記録
        end_time = time.perf_counter()
        inference_time = end_time - start_time
        print(f"推論時間: {inference_time:.4f} 秒")
        return wav