> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gravitex.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# 실시간 음성 (Realtime)

> WebSocket 실시간 음성 대화, OpenAI Realtime API 호환

## 소개

**WebSocket**을 통해 저지연 실시간 음성 세션을 구성합니다. [OpenAI Realtime API](https://platform.openai.com/docs/guides/realtime)와 호환됩니다. 연결 후 이벤트 프로토콜로 오디오와 텍스트를 주고받으며, 음성 비서, 실시간 번역 등에 적합합니다.

Base URL: `wss://api.gravitex.ai/v1/realtime`

<Note>
  일반 HTTP POST가 아닌 WebSocket 프로토콜을 사용해야 합니다. `model`은 **쿼리 파라미터**로 전달해야 하며, 그렇지 않으면 게이트웨이가 400을 반환합니다.
</Note>

## 인증

WebSocket 연결 시 다음 헤더를 포함합니다:

<ParamField header="Authorization" type="string" required>
  Bearer 토큰, 예: `Bearer sk-xxxxxxxxxx`
</ParamField>

<ParamField header="OpenAI-Beta" type="string" required>
  `realtime=v1` 고정
</ParamField>

브라우저에서는 WebSocket 헤더를 직접 설정할 수 없습니다. OpenAI 서브프로토콜로 키를 전달할 수 있습니다(헤더 방식과 동등, 게이트웨이에서 파싱):

```
Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxxxxxxxxx, openai-beta.realtime-v1
```

## 연결

```
wss://api.gravitex.ai/v1/realtime?model=gpt-realtime-1.5
```

<ParamField query="model" type="string" required>
  실시간 모델 ID, 예: `gpt-realtime-1.5`, `gpt-realtime-2`. 현재 API Key에서 사용 가능한 모델과 일치해야 합니다.
</ParamField>

연결 성공 시 서버는 **101 Switching Protocols**를 반환합니다. 이후 `session.created`를 수신하면 `session.update`를 전송해 세션을 구성합니다.

## 세션 구성

연결 후 `session.update`로 지시문, 출력 모달리티, 오디오 형식을 설정합니다:

```json theme={null}
{
  "type": "session.update",
  "session": {
    "type": "realtime",
    "instructions": "You are a concise assistant. Reply in Chinese. 回答用中文回答",
    "output_modalities": ["audio"],
    "audio": {
      "input":  { "format": { "type": "audio/pcm", "rate": 24000 } },
      "output": { "format": { "type": "audio/pcm", "rate": 24000 }, "voice": "sage" }
    }
  }
}
```

| 필드                    | 설명                                               |
| --------------------- | ------------------------------------------------ |
| `session.type`        | 항상 `realtime`                                    |
| `instructions`        | 시스템 지시문, 어시스턴트 동작 및 응답 언어 제어                     |
| `output_modalities`   | `["audio"]` 음성 출력; `["text"]` 텍스트만 출력(오디오 재생 없음) |
| `audio.input.format`  | 입력 오디오 형식: **PCM16, 24 kHz, 모노**                 |
| `audio.output.format` | 출력 오디오 형식: 위와 동일                                 |
| `audio.output.voice`  | 음색, 아래 선택값 참고                                    |

**선택 가능한 음색** (`output_modalities`에 `audio` 포함 시):

`alloy` · `ash` · `ballad` · `coral` · `echo` · `sage` · `shimmer` · `verse` · `marin`

`session.updated`를 수신한 후 대화를 시작합니다.

## 메시지 전송

각 턴은 두 단계로 구성됩니다:

1. `conversation.item.create` — 사용자 메시지 작성
2. `response.create` — 모델 응답 생성 트리거

### 텍스트 입력

```json theme={null}
{
  "type": "conversation.item.create",
  "item": {
    "type": "message",
    "role": "user",
    "content": [
      { "type": "input_text", "text": "안녕하세요, 중국어로 자기소개해 주세요" }
    ]
  }
}
```

### 오디오 입력

녹음은 **PCM16 raw bytes**(WAV 헤더 없음)여야 하며 Base64로 인코딩해 전송합니다:

```json theme={null}
{
  "type": "conversation.item.create",
  "item": {
    "type": "message",
    "role": "user",
    "content": [
      { "type": "input_audio", "audio": "<base64_pcm16>" }
    ]
  }
}
```

한 턴에 여러 입력을 조합할 수 있습니다. 예: 이미지 + 텍스트, 이미지 + 녹음:

```json theme={null}
{
  "type": "conversation.item.create",
  "item": {
    "type": "message",
    "role": "user",
    "content": [
      { "type": "ms_image", "image": "data:image/jpeg;base64,<base64>" },
      { "type": "input_text", "text": "이 이미지에 무엇이 있나요?" }
    ]
  }
}
```

<Warning>
  **이미지 입력 제한**: 일부 업스트림(예: Azure OpenAI Realtime)은 이미지를 지원하지 않으며 `input_text`와 `input_audio`만 지원합니다. 이미지 전송 시 서버 500 오류가 발생할 수 있습니다. Vision 기능은 Realtime이 아닌 Chat Completions / Responses API에서만 사용 가능합니다. [Microsoft 공식 설명](https://learn.microsoft.com/en-us/answers/questions/5706094/how-to-use-input-image-function-in-azure-gpt-realt) 참고. Base64 이미지 길이는 **1,048,576**자(원본 약 750 KB) 이하 권장.
</Warning>

메시지 작성 후 응답을 트리거합니다:

```json theme={null}
{ "type": "response.create" }
```

## 응답 수신

일반적인 서버 이벤트:

| 이벤트 유형                                                                       | 설명                                        |
| ---------------------------------------------------------------------------- | ----------------------------------------- |
| `response.text.delta` / `response.output_text.delta`                         | 텍스트 응답 증분 (`output_modalities: ["text"]`) |
| `response.audio_transcript.delta` / `response.output_audio_transcript.delta` | 오디오 응답의 텍스트 전사 증분                         |
| `response.audio.delta` / `response.output_audio.delta`                       | PCM16 오디오 증분(Base64), 디코딩 후 재생            |
| `response.done`                                                              | 해당 턴 종료, `usage` 포함                       |
| `error`                                                                      | 오류; 일부 업스트림 오류 후 연결이 종료되므로 재연결 필요         |

오디오 증분 필드는 `audio` 또는 `delta`일 수 있으므로 클라이언트는 둘 다 처리해야 합니다.

## 사용량

각 `response.done`은 **해당 Response의 사용량**만 반환하며, 세션 누적이 아닙니다. 공식 문서: *"The tokens used for a Response can be read from the response.done event"*. 다중 턴 대화에서는 턴별 사용량과 세션 누적 사용량을 함께 기록하는 것을 권장합니다.

`usage` 구조 예시:

```json theme={null}
{
  "total_tokens": 320,
  "input_tokens": 180,
  "output_tokens": 140,
  "input_token_details": {
    "text_tokens": 50,
    "audio_tokens": 130,
    "image_tokens": 0,
    "cached_tokens": 20,
    "cached_tokens_details": {
      "text_tokens": 8,
      "audio_tokens": 12
    }
  },
  "output_token_details": {
    "text_tokens": 40,
    "audio_tokens": 100
  }
}
```

### 필드 설명

| 필드                                   | 설명                                                                  |
| ------------------------------------ | ------------------------------------------------------------------- |
| `total_tokens`                       | `input_tokens` + `output_tokens`                                    |
| `input_tokens`                       | 모델에 전송된 전체 입력(**대화 기록 포함**)                                         |
| `input_token_details.text_tokens`    | 입력의 텍스트 토큰                                                          |
| `input_token_details.audio_tokens`   | 입력의 오디오 토큰                                                          |
| `input_token_details.image_tokens`   | 입력의 이미지 토큰                                                          |
| `input_token_details.cached_tokens`  | `input_tokens`의 **부분집합**(추가 합산이 아님), prompt cache 히트 부분; 캐시 가격으로 과금 |
| `cached_tokens_details.text_tokens`  | 캐시 중 텍스트 부분(OpenAI 네이티브 반환)                                         |
| `cached_tokens_details.audio_tokens` | 캐시 중 오디오 부분(OpenAI 네이티브 반환)                                         |
| `output_tokens`                      | 해당 턴 생성 토큰                                                          |
| `output_token_details.text_tokens`   | 출력의 텍스트 토큰, 즉 오디오 응답 transcript                                     |
| `output_token_details.audio_tokens`  | 출력의 오디오 토큰                                                          |

<Note>
  1. **output.text\_tokens**: 오디오 출력을 선택해도 자동 transcript로 텍스트 토큰이 발생하며, 정상 과금입니다. [OpenAI Realtime 과금](https://developers.openai.com/api/docs/guides/realtime-costs) 참고.
  2. **input.cached\_tokens**: `input_tokens`의 부분집합이며 추가 합산이 아닙니다. OpenAI는 `cached_tokens_details`(텍스트/오디오 분리)를 반환하고, 일부 업스트림(예: Azure GA)이 반환하지 않으면 게이트웨이가 텍스트/오디오 입력 비율로 추정합니다.
  3. **다중 턴 비용**: 각 Response는 전체 대화 기록을 전송하므로 턴이 진행될수록 `input_tokens`가 커집니다. 초기 턴은 prompt cache에 히트할 가능성이 높아 할인율이 큽니다(예: `gpt-realtime-2` 오디오 캐시 $0.40/1M, 오디오 입력 $32/1M 대비 98.75% 할인). 공식 문서: *"turns later in the session will be more expensive"*.
</Note>

## Python 전체 예제

의존성: `pip install websockets pyaudio`

```python theme={null}
import asyncio
import base64
import json
import websockets

BASE_URL = "wss://api.gravitex.ai/v1/realtime"
API_KEY  = "sk-xxxxxxxxxx"
MODEL    = "gpt-realtime-1.5"

async def main():
    url = f"{BASE_URL}?model={MODEL}"

    async with websockets.connect(url, additional_headers={
        "Authorization": f"Bearer {API_KEY}",
        "OpenAI-Beta": "realtime=v1",
    }) as ws:
        # 1. 세션 구성
        await ws.send(json.dumps({
            "type": "session.update",
            "session": {
                "type": "realtime",
                "instructions": "You are a concise assistant. Reply in Chinese.",
                "output_modalities": ["audio"],
                "audio": {
                    "input":  {"format": {"type": "audio/pcm", "rate": 24000}},
                    "output": {"format": {"type": "audio/pcm", "rate": 24000}, "voice": "sage"},
                },
            },
        }))

        # 2. 세션 준비 대기
        async for raw in ws:
            evt = json.loads(raw)
            if evt.get("type") in ("session.created", "session.updated"):
                break

        # 3. 텍스트 전송 및 응답 트리거
        await ws.send(json.dumps({
            "type": "conversation.item.create",
            "item": {
                "type": "message",
                "role": "user",
                "content": [{"type": "input_text", "text": "안녕하세요"}],
            },
        }))
        await ws.send(json.dumps({"type": "response.create"}))

        # 4. 응답 수신
        async for raw in ws:
            evt = json.loads(raw)
            t = evt.get("type", "")
            if t in ("response.text.delta", "response.output_text.delta",
                     "response.audio_transcript.delta", "response.output_audio_transcript.delta"):
                print(evt.get("delta", ""), end="", flush=True)
            elif t in ("response.audio.delta", "response.output_audio.delta"):
                audio_b64 = evt.get("audio", "") or evt.get("delta", "")
                if audio_b64:
                    # Base64 디코딩 후 PCM16 오디오 재생
                    pass
            elif t == "response.done":
                print()
                resp = evt.get("response", evt)  # GA 구조가 다를 수 있음
                usage = resp.get("usage")
                if usage:
                    print("이번 턴 사용량:", usage)
                break
            elif t == "error":
                print("오류:", evt)
                break

asyncio.run(main())
```

마이크 녹음, 스피커 재생, 이미지 입력, 턴별 및 누적 사용량 통계를 포함한 전체 상호작용 스크립트는 `test_realtime.py`를 참고하세요.

## FAQ

* **연결 400**: URL에 `?model=...` 쿼리 파라미터가 포함되어 있는지 확인하세요.
* **연결 401**: API Key가 유효하지 않거나 권한이 없습니다. `GET /v1/models`로 Key와 모델 가용성을 확인하세요.
* **전송 후 응답 없음**: `session.updated`를 수신했고 `response.create`를 전송했는지 확인하세요.
* **500 후 연결 종료**: 업스트림 오류로 WebSocket이 닫히므로 재연결이 필요합니다.
* **오디오 무음**: 24 kHz PCM16 모노인지, Base64 증분을 올바르게 디코딩했는지 확인하세요.

더 많은 이벤트 유형과 필드는 [OpenAI Realtime API 문서](https://platform.openai.com/docs/guides/realtime)를 참고하세요.
