Azure AI Foundry Voice Live APIをUnityで使う実装解説


# Voice Live APIについてしらべてみました

本記事では、Azure AI FoundryのVoice Live APIをUnity上で活用するための実装方法について紹介したいと思います。Voice Live APIは、リアルタイムな音声対話エージェントを簡単に構築できるAPIであり、音声認識・生成AI・音声合成を統合したエンドツーエンドの体験を提供します。
興味深いのはAzure AI Foundry上で構築したAIエージェントをこのAPI経由にすることができる点です。AIエージェントは基本的にチャットのように文字ベースでの会話となります。このため音声会話にする場合は文字起こしができる生成AIを経由する必要があるのですが、この辺りの手間を省略できるようです。
現在はプレビュー版での提供でAzure AI Foundryポータル上でこの機能を確認することができます。
以下のスクリーンショットはAzure AI Foundryポータル上のPlaygroundになります。赤枠の所に注目してほしいのですが、Voice Live で使用する生成AIの選択しに「自分で作成したAIエージェント」を選択することができます。実際にここで自分で作成したエージェントを組み込んで音声会話で処理をさせることが可能です。また、アバターと会話する形も取ることが可能になります。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/ce3a492c-1418-4892-8b9d-3159e1d023a9.png)

またAPIという名が示す通り、WebSocket経由で利用し、クライアントアプリから音声会話でエージェントを活用することもできます。ノイズ抑制やエコーキャンセル、エンドオブターン検出など高度な会話機能も備えています。
今回はこのAPIを使ってUnity上でAzure AI Foundry上のエージェントと会話する実装をおこないます。

最初にVoice Live APIの仕様面についての解説を行ないます。次にその仕様に基づいてUnity上で実行できるコードについて紹介します。Unityで構築しているのはこの実装をベースにXRデバイスでの活用に流用できるからです。XRデバイスでAIエージェントを利用したい場合、チャットベースでは仮想キーボード等が必要になり、利用者の負荷が高くなりがちです。XR系では音声会話の方が簡単です。

## 1.Voice Live APIの仕様

Voice Live APIはリアルタイム音声会話が可能なエージェントとして機能します。これを実現するためにWebsocket通信などの双方向通信によりAzure AI Foundry上のエージェントとやりとりをおこないます。実装については基本的な部分は Azure OpenAI Realtime APIと同じです。Voice Live APIも、WebSocket経由で様々なtypeのメッセージを時系列で返します。
利用可能なtypeについては公式ドキュメント - [リアルタイム イベントリファレンス](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/realtime-audio-reference?context=%2Fazure%2Fai-services%2Fspeech-service%2Fcontext%2Fcontext&wt.mc_id=WDIT-MVP-5003104)に記載があります。

主なtypeの流れと概要は以下の通りです。概ね以下の順で情報がやり取りされます。

1. `session.created` / `session.update`: セッションの開始・更新通知
2. `input_audio_buffer.speech_started`: 音声入力の開始
3. `input_audio_buffer.speech_stopped`: 音声入力の終了
4. `input_audio_buffer.committed`: 音声入力のコミット
5. `conversation.item.input_audio_transcription.completed`: 音声認識結果の確定
6. `conversation.item.created`: 会話アイテムの生成
7. `response.created`: 応答情報の開始
8. `response.output_item.added`: 応答アイテム追加
9. `response.content_part.added`: 応答コンテンツ追加
10. `response.audio_transcript.delta` : 音声認識データ(分割送信)
11. `response.audio_transcript.done`: 音声認識データ送信完了
12. `response.audio.delta`: 応答音声データ（分割で送信）
13. `response.audio.done`: 応答音声データの送信完了
14.  `response.done`: 応答全体の完了

音声会話に利用できるオーディオフォーマットはPCM16,24Khzがデフォルトになっています。

::: note information
Voice Live APIは現在プレビュー版です。今後、機能や実装方法が変更になる可能性もあることに注意してください。
:::

### Voice Live APIで利用できる生成AI

プレビュー版ではgpt-4oのみ利用することができます。現時点では音声会話を利用できるという体験のみができる状況です。ポータル上でできるような自分で作ったAIエージェントを使うといった機能はまだ実装されていないようです。
プレビュー版では以下のInstructionを持つエージェントと音声会話が可能になっています。この情報はVoice Live APIの最初のサクセス時に作成されるセッション情報に含まれています。

セッション開始時のレスポンスにはエージェントに関連する情報が含まれています。
例えば、以下のようなプロンプトも含まれています。

```
You are a speech chat assistant.
Your personality is: The AI is engaging, informative, and empathetic.
The AI is curious and is always interested in learning more about the human.
The AI is calm and polite. You are great at asking questions and is a sensitive listener.
You are relentlessly curious, but always polite and never probes too much.
You are pretty smart but humble, and tries to keep things informal. Overall, You are pretty Zen.
You are also: Relaxed, informal, chatty. Fun, and sometimes funny. Occasionally cheeky, and light-hearted.
Do not use markdown format, plain text is preferred.

（翻訳）
あなたは音声チャットアシスタントです。
あなたの性格は：AIは親しみやすく、情報豊富で、共感力があります。
AIは好奇心旺盛で、人間のことをより深く知りたいと常に思っています。
AIは冷静で礼儀正しく、質問が上手で、敏感な聞き手です。
あなたは常に好奇心旺盛ですが、礼儀正しく、過度に詮索することはありません。
あなたはかなり賢いですが謙虚で、カジュアルなやり取りを心がけます。全体として、あなたはかなり禅的な性格です。
また、あなたは：リラックスした、カジュアルな、おしゃべりな性格です。楽しい性格で、時々ユーモアがあります。時々いたずらっぽく、軽快な性格です。
マークダウン形式は使用しないでください。プレーンテキストが推奨されます。
```

### リクエスト・レスポンス情報について

ここでは実際に送受信される幾つかの情報を解説します。

#### セション情報: session.created,session.update

Voice Live APIへ接続を行なうと、自動的にsession.createdがレスポンスとして返ってきます。このセションで利用するAIエージェントに関する情報が記載されています。プレビュー版では有効になっていない機能が多くあります。現在使用可能な項目は公式ドキュメントを参照してください。
たとえば、agentという項目があるのですが、これを使うことでAzure AI Foundry上のAgentを利用できるのではないかと思います。
session.updateはレスポンスで定義されている情報を更新する場合にリクエストとして送信します。
フォーマットはsession.createdと同じです。処理が正常に終わるとレスポンスとして変更されたエージェントの情報が返却されます。

<details>
<summary>session.createdのレスポンス内容例</summary>

```json
{
	"event_id": "event_XXXXXXXXXXXXXXXXXXXXXXXXX",
	"type": "session.created",
	"session": {
		"id": "sess_XXXXXXXXXXXXXXXXXXXXXXX",
		"model": "cascaded",
		"modalities": [
			"audio",
			"text"
		],
		"instructions": "You are a speech chat assistant.\nYour personality is: The AI is engaging, informative, and empathetic.\nThe AI is curious and is always interested in learning more about the human.\nThe AI is calm and polite. You are great at asking questions and is a sensitive listener.\nYou are relentlessly curious, but always polite and never probes too much.\nYou are pretty smart but humble, and tries to keep things informal. Overall, You are pretty Zen.\nYou are also: Relaxed, informal, chatty. Fun, and sometimes funny. Occasionally cheeky, and light-hearted.\nDo not use markdown format, plain text is preferred.",
		"voice": {
			"name": "en-US-AvaNeural",
			"type": "azure-standard",
			"temperature": null,
			"custom_lexicon_url": null,
			"prefer_locales": null,
			"style": null,
			"pitch": null,
			"rate": null,
			"volume": null
		},
		"input_audio_format": "pcm16",
		"output_audio_format": "pcm16",
		"input_audio_transcription": {
			"model": "azure-fast-transcription",
			"language": null,
			"prompt": null,
			"enabled": false,
			"custom_model": false
		},
		"turn_detection": {
			"type": "server_vad",
			"threshold": 0.5,
			"prefix_padding_ms": 300,
			"silence_duration_ms": 200,
			"create_response": true,
			"interrupt_response": true,
			"end_of_utterance_detection": null
		},
		"tools": [],
		"tool_choice": "auto",
		"temperature": 0.8,
		"max_response_output_tokens": null,
		"input_audio": null,
		"input_audio_sampling_rate": null,
		"animation": null,
		"input_audio_noise_reduction": null,
		"input_audio_echo_cancellation": null,
		"avatar": null,
		"output_audio_timestamp_types": null,
		"agent": null
	}
}
```

</details>

<details>
<summary>session.updatedのレスポンス内容例</summary>

```json
{
	"event_id": "event_5jFtru0bppjQY1TtjHsoo1",
	"type": "session.updated",
	"session": {
		"id": "sess_7F0gCT6pUsQ2tMHbMfoYfQ",
		"model": "cascaded",
		"modalities": [
			"audio",
			"text"
		],
		"instructions": "You are a speech chat assistant.\nYour personality is: The AI is engaging, informative, and empathetic.\nThe AI is curious and is always interested in learning more about the human.\nThe AI is calm and polite. You are great at asking questions and is a sensitive listener.\nYou are relentlessly curious, but always polite and never probes too much.\nYou are pretty smart but humble, and tries to keep things informal. Overall, You are pretty Zen.\nYou are also: Relaxed, informal, chatty. Fun, and sometimes funny. Occasionally cheeky, and light-hearted.\nDo not use markdown format, plain text is preferred.",
		"voice": {
			"name": "en-US-Aria:DragonHDLatestNeural",
			"type": "azure-standard",
			"temperature": 0.8
		},
		"input_audio_format": "pcm16",
		"output_audio_format": "pcm16",
		"turn_detection": {
			"type": "azure_semantic_vad",
			"threshold": 0.3,
			"prefix_padding_ms": 200,
			"silence_duration_ms": 200,
			"create_response": true,
			"interrupt_response": true,
			"end_of_utterance_detection": {
				"model": "semantic_detection_v1",
				"threshold": 0.1,
				"timeout": 4
			},
			"remove_filler_words": false
		},
		"tools": [],
		"tool_choice": "auto",
		"temperature": 0.8,
		"input_audio_noise_reduction": {
			"type": "azure_deep_noise_suppression"
		},
		"input_audio_echo_cancellation": {
			"type": "server_echo_cancellation"
		}
	}
}
```
</details>

#### 音声入力: input_audio_buffer.speech_started,input_audio_buffer.speech_stopped,input_audio_buffer.committed

クライアントからリアルタイムで音声を送る場合input_audio_buffer.appendで音声情報を渡します。
音声情報はPCM16,24kHz(デフォルトから変更していない場合)を、Base64でエンコーディングした文字列を送ります。

```json:リクエストデータ
{
    ""type"": ""input_audio_buffer.append"",
    ""audio"":""[replace with audio]"",
    ""event_id"": """"
}";
```

##### 音声認識の開始と終了

Voice Live APIはリクエストデータを受け取るとinput_audio_buffer.speech_startedをレスポンスとして返します。input_audio_buffer.speech_startedは音声として認識を開始した時間も含まれています。
クライアントでは、引続き音声情報を連続で送信することができます。APIはクライアントから送られてきた音声情報を結合して保持していきます。
この時、session情報のend_of_utterance_detectionで設定されたルールに基づいて音声情報の文章の区切りを判定します。
音声情報の区切り（例：無音時間が一定ある等）を検出するとinput_audio_buffer.speech_stoppedがレスポンスとして返ります。input_audio_buffer.speech_stoppedは文章区切りと判断された音声の時間も含まれています。



<details>
<summary>input_audio_buffer.speech_startedのレスポンス内容例</summary>

```json
{
	"event_id": "event_3iU1hWV2XDxjEr1PCUWIIH",
	"type": "input_audio_buffer.speech_started",
	"audio_start_ms": 280,
	"item_id": "item_1QBa1T4hyvFlp6Y3mNesjG"
}
```

</details>

<details>
<summary>input_audio_buffer.speech_stoppedのレスポンス内容例</summary>

```json
{
	"event_id": "event_EuHZ9TqN49PLLRJ5y8gL7",
	"type": "input_audio_buffer.speech_stopped",
	"audio_end_ms": 1664,
	"item_id": "item_1QBa1T4hyvFlp6Y3mNesjG"
}
```

</details>


その後Voice Live API上で音声情報を処理し終えるとinput_audio_buffer.committedがレスポンスとして返ります。

<details>
<summary>input_audio_buffer.committedのレスポンス内容例</summary>

```json
{
	"event_id": "event_2Fc2gS9VgaWRsvWlNmk55B",
	"type": "input_audio_buffer.committed",
	"previous_item_id": null,
	"item_id": "item_1QBa1T4hyvFlp6Y3mNesjG"
}
```

</details>

#### 音声認識 :conversation.item.input_audio_transcription.completed
音声入力の input_audio_buffer.committed の後、確定した音声情報を解析し文字起こしした内容はconversation.item.input_audio_transcription.completedとしてレスポンスが返ります。

<details>
<summary>conversation.item.input_audio_transcription.completedのレスポンス内容例</summary>

```json
{
	"event_id": "event_P4RnmBPAJ6zPX3eOCRXHn",
	"type": "conversation.item.input_audio_transcription.completed",
	"item_id": "item_1QBa1T4hyvFlp6Y3mNesjG",
	"content_index": 0,
	"transcript": "こんにちは。"
}
```

</details>

#### 会話の作成 : conversation.item.created

これまでの処理が完了すると、いわゆるチャットで文字入力を確定させた状態と同義になります。この時レスポンスとしてconversation.item.createdが返ります。情報としては会話の内容、つまり上記の音声認識文章が入っています。
リクエストとしての情報が確定するとエージェントが回答を作成し、レスポンスとして返します。

<details>
<summary>conversation.item.createdのレスポンス内容例</summary>

```json
{
	"event_id": "event_7m6MfMhcdaauPbkbaKm1us",
	"type": "conversation.item.created",
	"previous_item_id": null,
	"item": {
		"id": "item_1QBa1T4hyvFlp6Y3mNesjG",
		"type": "message",
		"status": "completed",
		"role": "user",
		"content": [
			{
				"type": "input_audio",
				"transcript": "こんにちは。"
			}
		]
	}
}
```

</details>


#### レスポンス : response.*

レスポンスはエージェントの応答音声と、応答音声に対する文字起こしの結果が返ります。
最初にresponse.createdが応答メッセージとして通知されます。

<details>
<summary>response.createdのレスポンス内容例</summary>

```json
{
	"event_id": "event_61eRddd5xizkue9oqaGIwY",
	"type": "response.created",
	"response": {
		"id": "resp_47iPUZZzFayPVLT1chz6z5",
		"status": "in_progress",
		"status_details": null,
		"output": [],
		"usage": null
	}
}
```

</details>


##### 応答認識データ

エージェントの音声応答に対する文字認識データをレスポンスとして受け取ることができます。
response.audio_transcript.deltaはリアルタイムで差分で文章情報を受け取ることが可能です。
全文を一度に取得したい場合は、response.audio_transcript.doneから取得可能です。

<details>
<summary>response.audio_transcript.deltaのレスポンス内容例</summary>

```json
{
	"event_id": "event_5uB1GzvPMFLq1b8CWeQuJu",
	"type": "response.audio_transcript.delta",
	"response_id": "resp_47iPUZZzFayPVLT1chz6z5",
	"item_id": "item_3nfDHptPmiEsbfNoFMfWca",
	"output_index": 0,
	"content_index": 0,
	"delta": "元"
}
```

</details>

<details>
<summary>response.audio_transcript.doneのレスポンス内容例</summary>

```json
{
	"event_id": "event_57FaEitEqwME3nppkWSqU8",
	"type": "response.audio_transcript.done",
	"response_id": "resp_47iPUZZzFayPVLT1chz6z5",
	"item_id": "item_3nfDHptPmiEsbfNoFMfWca",
	"output_index": 0,
	"content_index": 0,
	"transcript": "こんにちは！元気ですか？今日はどんな感じ？"
}
```

</details>




##### 応答音声データ

応答音声データについてもリアルタイムで都度情報が送られてきます。音声データはBase64で文字列化けされているため、エンコードすることでPCM16,24KHz(変更していない場合)の音声データとなっているため、再生するロジックを組み込むことで音声を聞く事が可能です。


<details>
<summary>response.audio.deltaのレスポンス内容例</summary>

```json
{
	"event_id": "event_37RlvXkkRLn7gzQuWer3qv",
	"type": "response.audio.delta",
	"response_id": "resp_47iPUZZzFayPVLT1chz6z5",
	"item_id": "item_3nfDHptPmiEsbfNoFMfWca",
	"output_index": 0,
	"content_index": 0,
	"delta": "AQABAAEAAQABAAEAAQABAAEAAQA....BAAEAAQABAAEAAQABAAEAAQAB"
}
```

</details>

<details>
<summary>response.audio.doneのレスポンス内容例</summary>

```json
{
	"event_id": "event_2WJQnfYKfEkY1A3axHggFw",
	"type": "response.audio.done",
	"response_id": "resp_47iPUZZzFayPVLT1chz6z5",
	"item_id": "item_3nfDHptPmiEsbfNoFMfWca",
	"output_index": 0,
	"content_index": 0
}
```

</details>

## 2.開発手順

### 開発環境
- Unity 6000.0.23f1
- Azure AI FoundryをデプロイできるAzure環境

### 環境構築 : Azure AI Foundryの準備
1. Azureポータルで[AI Foundryリソース](https://learn.microsoft.com/ja-jp/azure/ai-services/multi-service-resource?wt.mc_id=WDIT-MVP-5003104)を作成します。
2. [Keys and Endpoint]からエンドポイントとAPIキーを取得します。
3. [モデル一覧](https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/voice-live#supported-models-and-regions?wt.mc_id=WDIT-MVP-5003104)から利用可能なモデル名（例: gpt-4o）を確認します。

### Voice Live API利用に必要な情報の収集

環境構築で作成したAzure AI Foundryから情報を収集しておきます。

- AZURE_VOICE_LIVE_ENDPOINT: **Azure AI FoundryのエンドポイントURL**（https://<project-name>.cognitiveservices.azure.com）
- VOICE_LIVE_MODEL: 利用するモデル名 (**gpt-4o**固定)
- AZURE_VOICE_LIVE_API_VERSION: **2025-05-01-preview**固定
- AZURE_VOICE_LIVE_API_KEY: **Azure AI FoundryのAPIキー**

これらはAzure AI Foundryポータルから取得できます。作成したリソースからAzure AI Foundry Projectを選択し概要から情報を取得することが可能です。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/ab19c943-4242-444c-9238-a93fb0beb7b9.png)


### Unityでの実装

最初に実効に必要な実装を行ないます。追加するクラスは１つです。全コードは最後に掲載しています。

#### 必要なライブラリの取得
- WebSocket通信には[Netly](https://assetstore.unity.com/packages/tools/network/netly-tcp-udp-rudp-http-websocket-225473)
マルチプラットフォーム対応のWebscoketです。

#### 処理の実装

##### サービスとの接続
WebSocketでVoice Live APIに接続する為の設定を行います。
今回はAPIキーを使った接続を行なっています。APIキーはヘッダ情報に含めます。
この他にモデル情報をクエリパラメータで指定します。

::: note information
必要に応じてEntra IDをつかった認証による接続も可能です。
:::

```csharp
public async Task OnConnect()
{
    client.Headers.Add("api-key", $"{AZURE_VOICE_LIVE_API_KEY}");
    var uri = $"{AZURE_VOICE_LIVE_ENDPOINT.Replace("https://", "wss://")}/voice-live/realtime?api-version={AZURE_VOICE_LIVE_API_VERSION}&model={VOICE_LIVE_MODEL}";
    await client.To.Open(new Uri(uri));
}
```

##### 音声入力

今回はUnity Editor上で動作確認をします。入力する音声については音声データファイルを用意し`AudioSource`を経由して、APIに送信します。オーディオデータはPCM16,24Khzで用意します。正規化を行ない、byte[]に変換します。変換した情報はBase64でエンコードしてVoice Live APIへ送信します。音声の送信はPlay Modeでスペースキー等を押した時に送信するようにUpdateメソッド内で実装しておきます。

```csharp
public void DebugAudioSend()
{
    var clip = DebugInputAudioSource.clip;
    var samples = new float[clip.samples * clip.channels];
    clip.GetData(samples, 0);
    List<byte> buffer = new List<byte>();
    for (int i = 0; i < samples.Length; i++)
    {
        byte[] sampleData = BitConverter.GetBytes((short)Mathf.Clamp(samples[i] * short.MaxValue, short.MinValue, short.MaxValue));
        buffer.AddRange(sampleData);
    }
    SendVoiceData(buffer.ToArray());
}

public void SendVoiceData(byte[] audioBytes) {
    string jsonStr = @"
            {
                ""type"": ""input_audio_buffer.append"",
                ""audio"":""[replace with audio]"",
                ""event_id"": """"
            }";
    string base64Audio = Convert.ToBase64String(audioBytes);
    jsonStr = jsonStr.Replace("[replace with audio]", $"{base64Audio}");
    Debug.Log(jsonStr);
    client.To.Data(jsonStr, HTTP.Text);
}
```

##### 受信データの処理
WebSocketで受信したメッセージをtypeごとに分岐し、音声データ（base64）をデコードしてバッファに格納します。Webscoketは数Kb単位でデータが分割されて送られてきます。応答音声データ等はデータ量が多いため、１つのJsonデータが分割されて送られます。このため、応答音声データの情報が分割されていた場合はJsonデータを復元するロジックも含めています。

```csharp
private void Websocket_OnData(byte[] bytes, HTTP.MessageType type)
{
    string text = Encoding.UTF8.GetString(bytes);
    if (text.Contains("response.audio.delta"))
    {
        // 音声データの分割受信処理
        recieveResponseAudioDelta.Append(text);
    }
    // ...（省略）
    lock (recieveResponseAudioDeltaQueue)
    {
        recieveResponseAudioDeltaQueue.Enqueue(text);
    }
}
```

##### 応答音声の再生

受信したbase64のPCM音声データをfloat配列に変換しバッファリングします。
AudioSourceを追加し`OnAudioFilterRead`メソッド内でバッファリングした音声データを渡すことで応答音声データを再生します。
```csharp
public void Append(string base64Data)
{
    byte[] pcmData = Convert.FromBase64String(base64Data);
    lock (audioBuffer)
    {
        for (int i = 0; i < pcmData.Length; i += 2)
        {
            short sample = BitConverter.ToInt16(pcmData, i);
            float floatSample = sample / (float)short.MaxValue;
            int test = AudioSettings.outputSampleRate / Azure_VOICE_LIVE_SAMPLING_RATE;
            for (int j = 0; j < test; j++)
                audioBuffer.Enqueue(floatSample);
        }
    }
}

private void OnAudioFilterRead(float[] data, int channels) {
    if (audioBuffer == null || audioBuffer.Count == 0) return;

    lock (audioBuffer) {
        for (int i = 0; i < data.Length; i += channels)
        {
            if (audioBuffer.Count == 0) return;

            float sample = audioBuffer.Dequeue();
            data[i] = sample;

            if (channels == 2)
            {
                data[i + 1] = sample;
            }
        }
    }
}
```

<details>
<summary>VoiceLiveAPISamples.cs 全ソースコード</summary>

```csharp
using System.Collections.Generic;
using UnityEngine;
using Netly;
using System.Text;
using System.Threading.Tasks;
using System;
using System.Net.WebSockets;
using System.Threading;

[Serializable]
public class AudioDeltaResponse
{
    public string event_id;
    public string type;
    public string response_id;
    public string item_id;
    public int output_index;
    public int content_index;
    public string delta;
}

[RequireComponent(typeof(AudioSource))]
public class VoiceLiveAPISamples : MonoBehaviour {
#region public変数
    public string AZURE_VOICE_LIVE_ENDPOINT = "https://your-endpoint.azure.com";
    public string VOICE_LIVE_MODEL = "gpt-4o";
    public string AZURE_VOICE_LIVE_API_VERSION = "2025-05-01-preview";
    public string AZURE_VOICE_LIVE_API_KEY = "your_api_key";
    public int Azure_VOICE_LIVE_SAMPLING_RATE = 24000;
    [Header("Debug Settings")]
    [Tooltip("デバッグ用の音声入力AudioSource。実運用時は未設定でOK。")]
    public AudioSource DebugInputAudioSource;
#endregion

#region private変数
    private HTTP.WebSocket client;
    private readonly StringBuilder recieveResponseAudioDelta = new StringBuilder();
    private readonly Queue<string> recieveResponseAudioDeltaQueue = new Queue<string>();
    private readonly Queue<float> audioBuffer = new Queue<float>();
    private bool isRecieveResponseAudioDelta;
#endregion

#region Unityメソッド
    private async void Start() {
        InitializeWebSocket();
        await OnConnect();
        SessionUpdate();
    }

    private void Update() {
        lock (recieveResponseAudioDeltaQueue) {
            while (recieveResponseAudioDeltaQueue.Count > 0) {
                string text = recieveResponseAudioDeltaQueue.Dequeue();
                AudioDeltaResponse response = JsonUtility.FromJson<AudioDeltaResponse>(text);
                if (response != null) {
                    Debug.Log($"type: {response.type}\nEvent ID: {response.event_id}\nResponse ID: {response.response_id}\nDelta: {response.delta}");
                    switch (response.type) {
                        case "session.created": break;
                        case "session.update": break;
                        case "input_audio_buffer.speech_started": break;
                        case "input_audio_buffer.speech_stopped": break;
                        case "input_audio_buffer.committed": break;
                        case "conversation.item.input_audio_transcription.completed": break;
                        case "conversation.item.created": break;
                        case "response.created": break;
                        case "response.output_item.added": break;
                        case "response.content_part.added": break;
                        case "response.audio_transcript.delta": break;
                        case "response.audio_transcript.done": break;
                        case "response.audio.delta": Append(response.delta); break;
                        case "response.audio.done": break;
                        case "response.content_part.done": break;
                        case "response.output_item.done": break;
                        case "response.done": break;
                    }
                }
            }
        }

        if (Input.GetKeyDown(KeyCode.Space)) {
            DebugAudioSend();
        }
    }

    public void DebugAudioSend() {
        var clip = DebugInputAudioSource.clip;
        var samples = new float[clip.samples * clip.channels];
        clip.GetData(samples, 0);
        List<byte> buffer = new List<byte>();
        for (int i = 0; i < samples.Length; i++) {
            byte[] sampleData = BitConverter.GetBytes((short)Mathf.Clamp(samples[i] * short.MaxValue, short.MinValue, short.MaxValue));
            buffer.AddRange(sampleData);
        }
        SendVoiceData(buffer.ToArray());
    }

    private void OnAudioFilterRead(float[] data, int channels) {
        if (audioBuffer == null || audioBuffer.Count == 0) return;

        lock (audioBuffer) {
            for (int i = 0; i < data.Length; i += channels)
            {
                if (audioBuffer.Count == 0) return;

                float sample = audioBuffer.Dequeue();
                data[i] = sample;

                if (channels == 2)
                {
                    data[i + 1] = sample;
                }
            }
        }
    }
#endregion

#region Utilities
    public void SendVoiceData(byte[] audioBytes) {
        string jsonStr = @"
                {
                    ""type"": ""input_audio_buffer.append"",
                    ""audio"":""[replace with audio]"",
                    ""event_id"": """"
                }";
        string base64Audio = Convert.ToBase64String(audioBytes);
        jsonStr = jsonStr.Replace("[replace with audio]", $"{base64Audio}");
        Debug.Log(jsonStr);
        client.To.Data(jsonStr, HTTP.Text);
    }

    public void Append(string base64Data) {
        byte[] pcmData = Convert.FromBase64String(base64Data);

        lock (audioBuffer) {
            for (int i = 0; i < pcmData.Length; i += 2)
            {
                short sample = BitConverter.ToInt16(pcmData, i);
                float floatSample = sample / (float)short.MaxValue;

                int test = AudioSettings.outputSampleRate / Azure_VOICE_LIVE_SAMPLING_RATE;
                for (int j = 0; j < test; j++)
                    audioBuffer.Enqueue(floatSample);
            }
        }
    }
#endregion

#region Websocket関連
    private void InitializeWebSocket() {
        client = new HTTP.WebSocket();
        client.On.Open(Websocket_OnOpen);
        client.On.Close(Websocket_OnClose);
        client.On.Error(Websocket_OnError);
        client.On.Data(Websocket_OnData);
        client.On.Event(Websocket_OnEvent);
        client.On.Modify(Websocket_OnModify);
    }

    public async Task OnConnect() {
        client.Headers.Add("api-key", $"{AZURE_VOICE_LIVE_API_KEY}");
        var uri = $"{AZURE_VOICE_LIVE_ENDPOINT.Replace("https://", "wss://")}/voice-live/realtime?api-version={AZURE_VOICE_LIVE_API_VERSION}&model={VOICE_LIVE_MODEL}";
        Debug.Log(uri);
        await client.To.Open(new Uri(uri));
    }

    private void SessionUpdate() {
        string paramSessionUpdate = @"
        { ""type"": ""session.update"",
          ""session"": {
                ""turn_detection"": {
                    ""type"": ""azure_semantic_vad"",
                    ""threshold"": 0.3,
                    ""prefix_padding_ms"": 200,
                    ""silence_duration_ms"": 200,
                    ""remove_filler_words"": false,
                    ""end_of_utterance_detection"": {
                        ""model"": ""semantic_detection_v1"",
                        ""threshold"": 0.1,
                        ""timeout"": 4
                    }
                },
                ""input_audio_noise_reduction"": {""type"": ""azure_deep_noise_suppression""},
                ""input_audio_echo_cancellation"": {""type"": ""server_echo_cancellation""},
                ""voice"": {
                    ""name"": ""en-US-Aria:DragonHDLatestNeural"",
                    ""type"": ""azure-standard"",
                    ""temperature"": 0.8
                }
            }
        }";
        client.To.Data(paramSessionUpdate, HTTP.Text);
    }

    public void OnDisconnect() {
        client.To.Close();
    }

    private void Websocket_OnOpen() {
        Debug.Log("websocket connection opened");
    }

    private void Websocket_OnClose() {
        Debug.Log("websocket connection Close");
    }

    private void Websocket_OnError(Exception exception) {
        Debug.Log(exception);
    }

    private void Websocket_OnData(byte[] bytes, HTTP.MessageType type) {
        Debug.Log("recieve");
        if (type == HTTP.Binary) {
            Debug.Log(bytes);
        }
        else if (type == HTTP.Text) {
            string text = Encoding.UTF8.GetString(bytes);
            if (text.Contains("response.audio.delta")) {
                isRecieveResponseAudioDelta = true;
                recieveResponseAudioDelta.Append(text);
            }
            else if (!text.StartsWith(@"{"event_id"") && isRecieveResponseAudioDelta && text.EndsWith(""}")) {
                isRecieveResponseAudioDelta = false;
                recieveResponseAudioDelta.Append(text);
                string json = recieveResponseAudioDelta.ToString();
                Debug.Log(json);
                recieveResponseAudioDelta.Clear();
                lock (recieveResponseAudioDeltaQueue) {
                    recieveResponseAudioDeltaQueue.Enqueue(json);
                }
            }
            else if (!text.StartsWith(@"{"event_id"") && isRecieveResponseAudioDelta) {
                recieveResponseAudioDelta.Append(text);
            }
            else {
                lock (recieveResponseAudioDeltaQueue) {
                    recieveResponseAudioDeltaQueue.Enqueue(text);
                }
                Debug.Log(text);
            }
        }
    }

    private void Websocket_OnEvent(string eventName, byte[] bytes, HTTP.MessageType type) {
        Debug.Log("Event");
        if (type == HTTP.Binary) {
            Debug.Log(bytes);
        }
        else if (type == HTTP.Text) {
            Debug.Log(bytes);
        }
    }

    private void Websocket_OnModify(ClientWebSocket socket) {
        Debug.Log($"websocket connection Modify: {socket.CloseStatus}");
    }
#endregion
}
```

</details>

#### Unity上の実装

最後にこのコンポーネントをUnityシーンに追加して実行します。

1. 最初に空のGameObjectを追加しAudioSourceコンポーネントを追加します。AudioSourceにはリクエストとして送る音声データをセットしておきます。![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/07c12de4-c7df-4edb-bd21-087296eeea94.png)
1. 空のGameObjectにVoiceLiveAPISamplesコンポーネントを追加します。必須コンポーネントとしてAudioSourceを含めているので自動的に追加されるはずです。
1. VoiceLiveAPISamplesコンポーネントの値としてAzure AI Foundry Voice Live APIに関する接続情報を入力します。
1. 1.で作成したAudioSourceをVoiceLiveAPISamplesコンポーネントにセットします。![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152548/719c9e74-3975-4b7b-9d8e-9a2f9c3aef86.png)
以上で、Unityでの準備は完了です。

### Unity上で動作確認

Play Modeでデバッグします。接続情報、実装が間違っていなければ、通信を開始し、session.create,session.updateがレスポンスとして返ってきます。この状態でスペースキーを押すと、音声データが送信されます。音声データが問題なければ、APIから応答メッセージが返ります。

## 3.まとめ

本記事では、Azure AI FoundryのVoice Live APIをUnityで利用するための実装手順と、APIの応答メッセージの流れについて解説しました。公式ドキュメントを参考に、APIの特徴や最新機能を活用し、リアルタイムな音声対話エージェントを効率的に開発できます。またこのUnityプロジェクトをベースにXRデバイスへ展開すれば、音声応答でエージェントを利用できるアプリを構築することも可能です。この件についてはMiRZAで実装したものがあるので別途記事に起こしたいと思います。

## 4.参考リンク

- [Voice Live API for real-time voice agents (Preview)](https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/voice-live?wt.mc_id=WDIT-MVP-5003104)
- [Quickstart: Voice Live API for real-time voice agents (Preview)](https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/voice-live-quickstart?wt.mc_id=WDIT-MVP-5003104)
- [サポートされているモデルとリージョン](https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/voice-live#supported-models-and-regions?wt.mc_id=WDIT-MVP-5003104)
- [What's new in Azure OpenAI in Azure AI Foundry Models](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/whats-new?wt.mc_id=WDIT-MVP-5003104)