GPT-Live 同時音声入出力 — 仕組みと Java 統合例
1. 概要
「GPT-Live」とは、Speech-to-Text → LLM → Text-to-Speech の順序処理ではなく、単一の音声ストリーミングチャネルを通じてリアルタイム LLM が同時にリッスンとスピークできる機能の総称です。この記事では、そのフルデュプレックス仕組みがプロトコルレイヤーでどう動作するか説明し、WebSocket に接続し、音声を送信し、ストリーミング応答を受信し、ユーザーが割り込む(barge-in)状況を処理する Spring Boot の例を構築します。記事を読み終わったら、リアルタイム音声 API を呼び出す独自の Java クライアントを書くのに十分な理解ができ、この機能を本番環境に導入する際の落とし穴も分かるようになります。
2. なぜ古いパイプライン (STT → LLM → TTS) は「ライブ」ではないのか
従来、音声アシスタントは通常 3 つの独立したモデルを組み合わせていました:
マイク → [Speech-to-Text] → テキスト → [LLM] → テキスト → [Text-to-Speech] → スピーカー
各ステップは前のステップが完了するのを待ってから実行されます。累積遅延(通常 2~5 秒)が生じ、ユーザーは応答の途中で割り込むことができません。システムが話している間は「リスニング」していないからです。
GPT-Live は 3 つの役割すべてを単一のマルチモーダルモデルに統合し、双方向(デュプレックス)の継続的な接続を通じて音声フレームを直接受け取り、音声フレームを生成します。「文が終わるまで待つ」というステップがありません。モデルは到着した小さな音声チャンク(chunk)を処理し、十分なセマンティック信頼性がある場合はユーザーが文を完全に終わらせる前に応答を開始できます。
注意: ここで「Live」は遅延がないという意味ではなく、遅延がパイプラインの終わりに集中するのではなく、チャンク単位(通常 20~100ms)で分散されるということです。
3. フルデュプレックスアーキテクチャ: WebSocket 経由のイベントストリーム
REST API (単一リクエスト-レスポンス)と異なり、リアルタイム API は WebSocket を使用してチャネルを開いたまま保ち、JSON 形式の イベント を相互にやり取りします。2つのデータストリームが同時に実行されます:
- クライアント → サーバー: 音声チャンク(base64 PCM16)、制御イベント(ターン開始/終了)。
- サーバー → クライアント: 応答音声チャンク、部分的なトランスクリプト、「モデルがあなたの発話を検出」を示すイベント(出力を自動中断するため)。
クライアント サーバー (GPT-Live)
|--- audio.append (chunk1) -->|
|--- audio.append (chunk2) -->|
| |-- transcript.delta (部分) -->
|--- audio.append (chunk3) -->|
|<------ response.audio.delta -| (モデルが応答を開始)
|--- audio.append (割り込み) -->|
|<------ response.cancelled ---| (サーバーが生成中の部分をキャンセル)
2つの音声ストリームが単一ソケット上で並列実行されることが、「同時リスニングとスピーキング」の本質です。
4. Spring Boot からの WebSocket 接続依存関係の設定
Spring Boot は spring-websocket を通じて WebSocket クライアント機能をネイティブにサポートしています。依存関係を追加します:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
リアルタイムエンドポイントに接続するための設定を作成します(例:wss://api.gptlive.example/v1/voice):
@Configuration
public class VoiceSessionConfig {
@Bean
public StandardWebSocketClient webSocketClient() {
return new StandardWebSocketClient();
}
}
StandardWebSocketClient は Spring の標準実装で、サーブレットコンテナを必要としないクライアント側の使用に十分です。
5. 音声チャンクの送信とストリーミング応答の受信
コアアイデア:マイクが音声セグメント(例:20ms)を記録するたびに、完全な文を待たずにサーバーに audio.append イベントをすぐに送信します。
public class VoiceSessionHandler extends TextWebSocketHandler {
private final ObjectMapper mapper = new ObjectMapper();
public void sendAudioChunk(WebSocketSession session, byte[] pcmChunk) throws IOException {
String base64Audio = Base64.getEncoder().encodeToString(pcmChunk);
Map<String, Object> event = Map.of(
"type", "input_audio.append",
"audio", base64Audio
);
session.sendMessage(new TextMessage(mapper.writeValueAsString(event)));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
JsonNode event = mapper.readTree(message.getPayload());
String type = event.get("type").asText();
switch (type) {
case "response.audio.delta" -> playAudioChunk(event.get("audio").asText());
case "response.text.delta" -> System.out.print(event.get("delta").asText());
case "input_audio.speech_started" -> cancelOngoingPlayback();
default -> { /* 処理不要なイベントをスキップ */ }
}
}
private void playAudioChunk(String base64Audio) {
byte[] pcm = Base64.getDecoder().decode(base64Audio);
// PCM をスピーカーの音声出力バッファにプッシュします
}
private void cancelOngoingPlayback() {
// 再生中の音声を停止します。これが barge-in メカニズムです
}
}
説明:
sendAudioChunkはマイク記録スレッドから継続的に(例:20ms ごと)呼び出され、低遅延を保つためにバッチ処理されません。handleTextMessageはサーバーからのイベントを受け取るコールバックです。response.audio.deltaはモデルが話していることを意味し、input_audio.speech_startedはサーバーがユーザーの発話を検出したことを意味します。この時点でクライアントはスピーカーをすぐに停止する必要があります。event.get("type").asText()は Jackson のJsonNodeを使用してtypeフィールドを読み取り、DTO を厳密に定義する必要がありません。イベントスキーマが新しいタイプで拡張される可能性があるため便利です。
6. Barge-in メカニズム:誰がいつ話すのか
Barge-in はユーザーが AI を文の途中で割り込む能力です。これを実現するために、サーバーはサーバーが現在音声を出力しているかどうかに関係なく、受信音声ストリーム上で継続的に実行される Voice Activity Detection(VAD)検出器が必要です。
public class TurnStateMachine {
public enum State { IDLE, USER_SPEAKING, MODEL_SPEAKING }
private volatile State state = State.IDLE;
public void onServerEvent(String eventType) {
state = switch (eventType) {
case "input_audio.speech_started" -> {
// ユーザーがモデルの応答中に話す → 割り込み
if (state == State.MODEL_SPEAKING) {
stopPlaybackImmediately();
}
yield State.USER_SPEAKING;
}
case "input_audio.speech_stopped" -> State.IDLE;
case "response.audio.delta" -> State.MODEL_SPEAKING;
case "response.done" -> State.IDLE;
default -> state;
};
}
private void stopPlaybackImmediately() {
System.out.println("音声再生を停止、サーバーに response.cancel を送信");
}
}
説明: TurnStateMachine は 3 つの状態を使用して誰が「マイクを保持」しているかを追跡します。MODEL_SPEAKING 状態の間に input_audio.speech_started が到着した場合、ユーザーが割り込んでいることを意味します。クライアントはスピーカーをすぐに停止し、サーバーに生成中の応答をキャンセルするよう指示する必要があります(response.cancel イベントを送信)。古い音声と新しい音声が重なるのを避けるためです。
注意: Barge-in を処理しないと、AI がユーザーの声に被せて話します。これは音声アシスタントの最も不快な経験です。これはクイックデモでは簡単に見落とされがちですが、本番環境では必須です。
7. 会話のセッションとコンテキスト管理
接続がステートフル(コール全体を通じて維持)であるため、会話履歴と音声設定を保存するセッションオブジェクトが必要で、トランスポートロジックから分離する必要があります。
public class VoiceSession {
private final String sessionId;
private final List<ConversationTurn> history = new ArrayList<>();
private final String voice; // 例:"alloy"、"verse"
public VoiceSession(String sessionId, String voice) {
this.sessionId = sessionId;
this.voice = voice;
}
public void appendTurn(String role, String transcript) {
history.add(new ConversationTurn(role, transcript, Instant.now()));
}
public record ConversationTurn(String role, String transcript, Instant at) {}
}
セッションを初期化するときに、音声レコーディングを開始する前に、session.update イベントを送信して音声とシステムインストラクションを設定します:
Map<String, Object> sessionUpdate = Map.of(
"type", "session.update",
"session", Map.of(
"voice", "alloy",
"instructions", "あなたは医師の予約をする予約アシスタントです。簡潔に答えてください。",
"turn_detection", Map.of("type", "server_vad")
)
);
turn_detection: server_vad は、ユーザーが文を終了したときの検出をクライアントが沈黙から推測するのではなく、サーバーの VAD 処理に委譲することを意味します。これはより正確です。サーバーはセマンティックシグナルを使用できるためです(例:文法的に不完全な文はまだ終了していないと見なされません)。
8. JUnit による turn-taking ロジックのテスト
トランスポートレイヤー(実際の WebSocket)は単体テストが難しいですが、ターンテイキングを制御する状態マシンは完全に独立してテストできます。
class TurnStateMachineTest {
@Test
void whenModelSpeakingAndUserStartsSpeaking_thenMustStopPlayback() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.audio.delta");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.MODEL_SPEAKING);
machine.onServerEvent("input_audio.speech_started");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.USER_SPEAKING);
}
@Test
void whenNoOneSpeaking_thenStatusIsIdle() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.done");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.IDLE);
}
}
(TurnStateMachine に getter getState() を追加していると仮定します。)最初のテストは割り込みがある場合に状態遷移が正しい順序で発生することを確認し、2 番目のテストは応答が完了した後にシステムが IDLE に戻ることを確認します。これは次の会話ループがきれいに開始されるための条件です。
9. よくあるエラーと使用すべきでない場合
- 大量バッチでの音声送信(例:2 秒ごと): ストリーミングのすべてのメリットを排除し、遅延が再び古いパイプラインのようになります。常に小さなチャンク(20~100ms)を送信してください。
response.cancelイベントを処理しない: クライアント側で再生を停止するだけでサーバーに取り消しを通知しない場合、サーバーは不要な音声を生成し続け、コストを浪費し、セッション状態のずれを引き起こす可能性があります。- クライアント側 VAD を使用してユーザーが発話を終了したときを自動判定: ノイズの多い環境では文を早すぎたり遅すぎたりカットするリスクがあります。API がサポートしている場合はサーバー VAD に処理させる方が良いです。オフライン操作が必要な場合を除いて。
- リアルタイムインタラクションが不要なユースケースに適用する(例:レポート作成、長いドキュメントの要約):リアルタイム音声モデルは従来の STT/LLM/TTS パイプラインよりはるかに高価で複雑です。不要な低遅延との引き換えには価値がありません。
- UI メインスレッドで WebSocket クライアントを実行すること。レコーディング/再生スレッドを分離しない場合:JSON パース処理が音声スレッドをブロックすると、途切れやつまりが発生します。
注意: フルデュプレックス音声はテキストトークンだけでなく、コール全体を通じて継続的に帯域幅と API コストを消費します。セッション期間を制限するか、アクティビティがないときにタイムアウトさせることを検討してください。
10. 結論
GPT-Live は、開いたままの WebSocket 接続に基づいて動作し、音声チャンク双方向で送信し、VAD を使用して誰がスピーキングターンを保有しているかを判定します。これが、古い STT/LLM/TTS パイプラインのように順序待つのではなく、「同時リスニングとスピーキング」を可能にするメカニズムです。Java/Spring Boot アプリケーションに統合するときは、ターンテイキング状態マシンを管理し、barge-in を正しく処理し、セッションロジックをトランスポートから分離してテストを容易にすることが主な作業になります。ユースケースがリアルタイム応答を必要としない場合、従来のパイプラインは依然としてシンプルで安価なオプションです。