# 실시간 변환

> 마이크나 탭 오디오를 WebSocket으로 transcript1에 스트리밍하면, 말하는 동안 자막 줄을 받아요. 실시간 번역도 함께 쓸 수 있어요.

Canonical: https://transcript1.com/ko/docs/live
Language: ko
Last updated: 2026-11-03

실시간 변환은 사람이 말하는 동안 오디오 스트림을 자막 줄로 바꿔 줘요. 클라이언트가 WebSocket으로 원시 PCM 오디오를 보내면, transcript1이 처음에는 임시 결과였다가 나중에 확정되는 줄을 돌려줘요. `translate_to`를 추가하면 모든 줄에 번역도 함께 담겨요. 세션이 끝나면 결과는 일반 변환 작업으로 저장돼요.

실시간 변환 전용 요금제는 따로 없어요. 다른 기능과 같은 분량 잔액을 사용해요. 연결 시간을 어떻게 세는지는 [과금](https://transcript1.com/ko/docs/live#billing)을 확인하세요.

## 실시간 변환이 맞는 경우

| 이럴 때 실시간 변환 | 이럴 때 [일반 변환](https://transcript1.com/ko/docs/getting-started) |
|---|---|
| 통화, 강연, 스트림, 지금 보고 있는 영상처럼 오디오가 아직 만들어지는 중이에요. | 링크나 완성된 파일이 있어요. |
| 말하고 나서 1~2초 안에 자막이 필요해요. | 결과를 기다릴 수 있고 가장 높은 정확도를 원해요. |
| 각 줄 옆에 실시간 번역이 필요해요. | 완성된 스크립트의 번역이나 여러 언어 번역이 필요해요. |

실시간 변환은 원시 PCM 오디오만 받아요. 동영상 링크, 업로드, WebM, Opus, MP3는 받지 않아요. 클라이언트에서 오디오를 캡처해 먼저 PCM으로 변환하세요. 방법은 [브라우저에서 오디오 캡처하기](https://transcript1.com/ko/docs/live#capture-audio-in-a-browser)에 있어요.

## SDK로 빠르게 시작하기

SDK 1.5.0 이상을 설치하세요: `npm i @transcript1/sdk@latest`. `t1.live.start`는 세션을 만들고 연결한 뒤, 세션이 시작되면 완료돼요. 그래서 바로 오디오를 보낼 수 있어요.

```javascript
import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';
import { Transcript1 } from '@transcript1/sdk';

const t1 = new Transcript1(); // reads TRANSCRIPT1_API_KEY

const live = await t1.live.start({ language: 'en', translateTo: 'es', sampleRate: 16000 });

live.on('line', line => {
  const mark = line.final ? 'final  ' : 'partial';
  console.log(mark, line.text, '→', line.translation?.text ?? '');
});
live.on('warning', warning => console.warn(warning.code));

// speech.pcm: 16 kHz, mono, signed 16-bit little-endian, no header
for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
  live.send(chunk);
  await sleep(100); // 3,200 bytes are 100 ms of audio: send at real-time speed
}

const result = await live.end();
console.log(result.reason, result.transcriptionId, result.minutesCharged);
```

줄은 그 자리에서 갱신돼요. 같은 `id`의 줄이 도착하면 가지고 있던 줄을 교체하세요. 모든 줄은 `live.lines`로도 읽을 수 있어요. `end()`는 세션을 마치고 마지막 줄을 기다린 뒤 스크립트를 저장해요. 모든 메서드와 이벤트는 [Node.js SDK](https://transcript1.com/ko/docs/sdk#live-transcription)를 확인하세요.

## 원시 WebSocket으로 빠르게 시작하기

SDK 없이 쓰려면 두 단계예요. 먼저 HTTPS로 세션을 만들고, 응답으로 받은 일회용 토큰으로 WebSocket을 열어요.

### 세션 만들기

```bash
curl -X POST https://api.transcript1.com/v1/live/sessions \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"language": "en", "translate_to": "es", "sample_rate": 16000}'
```

API는 `201`로 응답해요.

```json
{
  "id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "status": "waiting",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "websocket_url": "wss://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD/stream",
  "token": "Zk3x...",
  "token_expires_at": "2026-11-02T12:01:00Z",
  "rate": 1
}
```

`save`가 `false`이면 `transcription_id`는 `null`이에요. 토큰은 일회용이고 60초 뒤에 만료되니 바로 연결하세요. `rate`는 이 세션에서 시작한 1분마다 차감되는 잔액 분량이에요. [과금](https://transcript1.com/ko/docs/live#billing)을 참고하세요.

요청 본문에는 다음 필드를 쓸 수 있어요. 모두 선택 사항이고, 필요한 스코프는 `transcriptions:write`예요.

| 필드 | 기본값 | 규칙 |
|---|---|---|
| `language` | `auto` | `auto` 또는 실시간 변환이 지원하는 두 글자 언어 코드예요. |
| `translate_to` | 없음 | 두 글자 언어 코드예요. 세션당 번역 대상 언어는 하나예요. [실시간으로 번역하기](https://transcript1.com/ko/docs/live#translate-live)를 참고하세요. |
| `diarize` | `false` | 화자에 라벨을 붙여요. `language: "auto"`와 함께 쓰는 경우처럼 화자 라벨을 지원하지 않는 조합은 `422`로 응답해요. [지원 범위](https://transcript1.com/ko/docs/live#capabilities)를 확인하세요. |
| `audio_format` | `pcm_s16le` | 유일한 값이에요. 나중에 형식을 추가할 수 있도록 필드를 만들어 뒀어요. |
| `sample_rate` | `16000` | 8000에서 48000 사이의 정수예요. 모노만 지원해요. |
| `save` | `true` | `false`이면 세션이 끝난 뒤 아무것도 남기지 않아요. [저장된 스크립트](https://transcript1.com/ko/docs/live#saved-transcripts)를 참고하세요. |

### 연결하고 스트리밍하기

토큰을 두 번째 서브프로토콜로 넣어 WebSocket을 열어요. 브라우저와 Node 22 이상에서는 별도 헤더 없이 동작해요. 오디오는 `session.started`를 받은 뒤에 보내세요. 그 전에 보낸 바이너리 프레임은 버려지고 `audio_dropped` 경고가 와요.

```javascript
import { createReadStream } from 'node:fs';
import { setTimeout as sleep } from 'node:timers/promises';

const session = await fetch('https://api.transcript1.com/v1/live/sessions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.TRANSCRIPT1_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ language: 'en', translate_to: 'es', sample_rate: 16000 }),
}).then(response => response.json());

const ws = new WebSocket(session.websocket_url, ['t1-live', session.token]);
const lines = new Map();

ws.addEventListener('message', async message => {
  const event = JSON.parse(message.data);

  if (event.type === 'session.started') {
    for await (const chunk of createReadStream('./speech.pcm', { highWaterMark: 3200 })) {
      ws.send(chunk);
      await sleep(100);
    }
    ws.send(JSON.stringify({ type: 'end' }));
  } else if (event.type === 'line') {
    lines.set(event.line.id, event.line); // the full current state: replace by id
    console.log(event.line.final, event.line.text, event.line.translation?.text);
  } else if (event.type === 'session.ended') {
    console.log('ended:', event.reason, event.minutes_charged);
  }
});

ws.addEventListener('close', event => console.log('closed', event.code));
```

Node 20에는 내장 `WebSocket`이 없어요. 같은 `new WebSocket(url, protocols)` 형태를 쓰는 `ws` 패키지를 사용하세요.

유효한 토큰이면 서버는 `t1-live` 서브프로토콜만 응답해요. 잘못된 토큰이어도 핸드셰이크가 실패하지는 않아요. 소켓이 열리고 `error` 이벤트가 온 뒤 4xxx 코드로 닫혀요. 그래서 브라우저에서도 막연한 1006 대신 실제 이유를 볼 수 있어요. [종료 코드](https://transcript1.com/ko/docs/live#close-codes)를 참고하세요.

## 브라우저에서 오디오 캡처하기

실시간 오디오는 세션의 `sample_rate`로 맞춘 16비트 부호 있는 리틀 엔디언 모노 PCM이어야 해요. 브라우저는 부동 소수점 샘플을 주기 때문에 `AudioWorklet`에서 변환해야 해요. 아래 코드는 마이크를 16 kHz PCM으로 100 ms 단위로 캡처해서 브라우저용 SDK 진입점으로 보내요. `getAccessToken`은 OAuth 액세스 토큰을 반환해요. 브라우저에는 절대 API 키를 넣지 마세요. [보안](https://transcript1.com/ko/docs/live#security)을 참고하세요.

```javascript
import { createLiveClient } from '@transcript1/sdk/live';

const workletSource = `
class Pcm16Processor extends AudioWorkletProcessor {
  constructor() {
    super();
    this.buffer = new Int16Array(1600); // 100 ms at 16 kHz
    this.filled = 0;
  }

  process(inputs) {
    const channel = inputs[0][0];
    if (!channel) return true;
    for (let i = 0; i < channel.length; i++) {
      const sample = Math.max(-1, Math.min(1, channel[i]));
      this.buffer[this.filled++] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
      if (this.filled === this.buffer.length) {
        this.port.postMessage(this.buffer.slice());
        this.filled = 0;
      }
    }
    return true;
  }
}
registerProcessor('pcm16', Pcm16Processor);
`;

const client = createLiveClient({ getAccessToken: () => myApp.getOAuthAccessToken() });

const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1 } });
const context = new AudioContext({ sampleRate: 16000 });
const moduleUrl = URL.createObjectURL(new Blob([workletSource], { type: 'text/javascript' }));
await context.audioWorklet.addModule(moduleUrl);

// the context's real rate: a browser that cannot do 16 kHz picks its own
const live = await client.start({ language: 'auto', sampleRate: context.sampleRate });
live.on('line', renderLine);

const microphone = context.createMediaStreamSource(stream);
const capture = new AudioWorkletNode(context, 'pcm16');
capture.port.onmessage = event => live.send(event.data); // an Int16Array
const mute = context.createGain();
mute.gain.value = 0;
microphone.connect(capture).connect(mute).connect(context.destination);

async function stop() {
  stream.getTracks().forEach(track => track.stop());
  await context.close();
  return live.end();
}
```

이 워크릿은 16 kHz 기준으로 작성돼 있어요. 컨텍스트가 다른 샘플레이트로 동작하면 청크가 더 이상 100 ms는 아니지만, `sampleRate`가 컨텍스트와 같기 때문에 PCM 자체는 올바르게 유지돼요. 마이크 대신 탭이나 동영상의 소리를 캡처하려면 `getDisplayMedia`나 미디어 요소의 `captureStream()`으로 `MediaStream`을 만들고 같은 방식으로 연결하세요.

## 실시간으로 번역하기

세션에 `translate_to`를 추가하면 transcript1이 말하는 대로 모든 줄을 번역해요.

- 세션당 번역 대상 언어는 하나예요. 언어를 더 추가하려면 나중에 저장된 스크립트에 [번역 엔드포인트](https://transcript1.com/ko/docs/live#saved-transcripts)로 추가하세요.
- 모든 `line`에는 원문(`text`, `final`)과 `translation` 객체(`language`, `text`, `final`)가 있어요. `translate_to`를 지정하지 않으면 `translation`은 `null`이에요.
- 원문과 번역은 각자 따로 확정돼요. 원문은 확정됐는데 번역은 아직 바뀌는 중일 수 있어요. 둘 다 확정된 뒤에 그 줄을 확정으로 표시하세요.
- 이미 번역 대상 언어로 말한 줄은 그 줄의 텍스트를 번역으로 쓰고, 줄이 닫힐 때 확정돼요.
- 줄은 화자가 말을 멈추거나, 화자가 바뀌거나, 단어 사이 간격이 0.9초보다 길거나, 줄이 자막 두 줄(84자) 또는 15초에 이르거나, `finalize`를 보내면 닫혀요. 번역을 켜면 번역이 도착하는 동안 줄이 닫히기까지 3초 정도 더 걸릴 수 있어요. 이때 원문이 확정된 뒤에도 번역은 열린 채(`final: false`) 계속 늘어날 수 있어요.
- 번역은 무료예요. 분량을 쓰지 않아요.

어떤 번역 대상 언어와 언어 쌍을 쓸 수 있는지는 실시간 음성 인식 제공자에 따라 달라요. 규칙을 하드코딩하지 말고 [지원 범위](https://transcript1.com/ko/docs/live#capabilities)를 읽으세요. 지원하지 않는 대상 언어는 `422 unsupported_translation`을 반환해요. 예를 들어 제공자에 따라 `auto` 대신 `language`를 명시해야 하거나, 영어가 포함된 쌍만 허용할 수 있어요.

## WebSocket 프로토콜

### 연결

```javascript
new WebSocket(websocket_url, ['t1-live', token]);
```

토큰은 WebSocket 서브프로토콜로 전달돼요. URL이나 액세스 로그에는 남지 않아요. `Authorization` 헤더는 보내지 마세요. 인증 수단은 토큰이에요. 서버는 30초마다 ping을 보내고, 브라우저와 `ws` 패키지는 자동으로 응답해요. 60초 동안 pong이 없는 소켓은 끊겨요.

이미 소켓이 있는 세션에 유효한 연결을 하면 이전 소켓을 대체해요. [재개와 인수](https://transcript1.com/ko/docs/live#resume-and-takeover)를 참고하세요.

### 클라이언트 메시지

| 메시지 | 의미 |
|---|---|
| 바이너리 프레임 | 오디오예요. 세션의 `sample_rate`에 맞는 PCM s16le 모노예요. 프레임 길이는 짝수 바이트이고 최대 256 KB예요. 프레임당 100 ms 정도가 적당해요. |
| `{"type":"finalize"}` | 현재 줄을 지금 닫아요. 예를 들어 영상을 일시정지했을 때 써요. |
| `{"type":"end"}` | 세션을 마치고 저장해요. 서버가 `session.ended`로 응답하고 소켓을 닫아요. |
| 그 외 | 코드가 `invalid_message`인, 치명적이지 않은 `error` 이벤트가 와요. |

홀수 길이이거나 너무 큰 프레임은 프로토콜 위반이라 소켓이 `4008`로 닫혀요.

오디오는 `session.started` 이후에 보내기 시작하세요. 실시간보다 빠르게 오디오를 보내면 연결 시간 대신 오디오 길이로 과금돼요([과금](https://transcript1.com/ko/docs/live#billing) 참고). 오디오가 만들어지는 속도에 맞춰 보내세요.

### 서버 이벤트

모든 이벤트는 `type`이 있는 JSON 텍스트 프레임이에요.

| 이벤트 | 보내는 시점 |
|---|---|
| `session.started` | 연결이 수락됐어요. 이제부터 오디오를 보내세요. |
| `line` | 자막 줄이 만들어졌거나 바뀌었어요. |
| `usage` | 과금될 때마다 와요. |
| `warning` | 주의가 필요하지만 세션은 계속돼요. |
| `error` | 치명적이지 않은 문제이거나, 곧바로 연결을 닫는 이유예요. |
| `reconnect` | 서버가 곧 재시작돼요. 세션을 재개하세요. |
| `session.ended` | 세션이 끝나 저장됐어요. 정상 종료 직전의 마지막 이벤트예요. |

`session.started`는 재개인지 여부와, 세션이 지금까지 처리한 오디오 양을 알려줘요.

```json
{
  "type": "session.started",
  "session_id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "resumed": false,
  "offset_ms": 0
}
```

`line`에는 한 줄의 최신 상태 전체가 담겨요. 같은 `id`의 줄을 이 값으로 교체하세요. [line 객체](https://transcript1.com/ko/docs/live#the-line-object)를 참고하세요.

```json
{
  "type": "line",
  "line": {
    "id": 12,
    "start_ms": 53120,
    "end_ms": 56040,
    "speaker": null,
    "language": "en",
    "text": "Welcome back to the stream",
    "final": true,
    "translation": { "language": "es", "text": "Bienvenidos de nuevo", "final": false }
  }
}
```

`usage`는 과금될 때마다 와요. 연결 시간, 오디오 길이, 지금까지 차감된 잔액 분량, 남은 분량이 들어 있어요.

```json
{
  "type": "usage",
  "connected_ms": 61240,
  "audio_ms": 60100,
  "minutes_charged": 2,
  "minutes_remaining": 898
}
```

`warning`에는 `code`가 있어요.

| `code` | 의미 |
|---|---|
| `balance_low` | 다음 과금이 실패할 거예요. 그 시점이 되면 세션이 `balance_exhausted`로 끝나요. [자동 충전](https://transcript1.com/ko/docs/billing-and-limits#auto-top-up)이 켜져 있으면 보내지 않아요. |
| `audio_dropped` | 세션이 준비되기 전에 오디오가 도착했거나, 음성 인식 제공자에 연결되는 동안 오디오가 너무 많이 도착했어요. 가장 오래된 오디오부터 버려졌어요. |
| `idle_timeout_soon` | 4분 동안 오디오가 없었어요. 오디오 없이 5분이 지나면 세션이 끝나요. 유지하려면 오디오를 보내세요. |

```json
{ "type": "warning", "code": "idle_timeout_soon" }
```

`error`에는 `code`와 `message`가 있어요. 실시간 소켓에서는 다음 코드가 올 수 있어요.

| `code` | 치명적 여부 | 의미 |
|---|---|---|
| `invalid_message` | 아니요 | `finalize`나 `end`가 아닌 텍스트 프레임이에요. |
| `invalid_token` | 예 | 토큰이 유효하지 않거나, 만료됐거나, 이미 사용됐어요. 소켓이 `4001`로 닫혀요. |
| `not_found` | 예 | 세션이 없어요. 소켓이 `4004`로 닫혀요. |
| `session_ended` | 예 | 세션이 이미 끝났어요. 소켓이 `4004`로 닫혀요. |

```json
{ "type": "error", "code": "invalid_message", "message": "Expected {\"type\":\"finalize\"} or {\"type\":\"end\"}" }
```

`reconnect`는 서버가 소켓을 `1012`로 닫기 전에 와요. 예를 들어 배포 중에 오는 이벤트예요. 새 토큰을 받아 세션을 재개하세요.

```json
{ "type": "reconnect", "reason": "server_restart" }
```

`session.ended`는 모든 세션의 마지막 이벤트예요. 숫자는 최종 합계예요.

```json
{
  "type": "session.ended",
  "reason": "client_ended",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "minutes_charged": 3,
  "connected_ms": 142500,
  "audio_ms": 141900
}
```

### line 객체

| 필드 | 의미 |
|---|---|
| `id` | 줄마다 1씩 늘어나는 0부터 시작하는 번호예요. 저장된 스크립트의 세그먼트 인덱스이기도 해요. |
| `start_ms`, `end_ms` | 세션 오디오 시간 기준의 시작과 끝이에요. 이 시간은 재연결 후에도 이어져요. |
| `speaker` | `diarize`가 켜져 있으면 화자 라벨, 아니면 `null`이에요. 라벨은 일반 스크립트와 같아요. |
| `language` | 감지했거나 지정한 줄의 언어예요. 아직 모르면 `null`이에요. |
| `text` | 지금까지의 원문이에요. |
| `final` | 원문이 더 이상 바뀌지 않으면 `true`예요. |
| `translation` | `translate_to`가 없으면 `null`이고, 있으면 `language`, `text`, `final`이 들어 있어요. |

`final: false`인 줄은 텍스트가 더 바뀔 수 있으니 연한 스타일로 표시하세요. `final: true`인 줄의 텍스트는 다시 바뀌지 않고, 확정된 줄이 삭제되는 일도 없어요.

SDK는 줄 필드를 `camelCase`로 써요: `startMs`, `endMs`.

### 종료 사유

`session.ended`와 `GET /v1/live/sessions/:id`는 `end_reason`을 알려줘요.

| 사유 | 의미 |
|---|---|
| `client_ended` | 클라이언트가 `end`를 보냈어요. |
| `balance_exhausted` | 다음 과금이 실패했어요. 세션은 분 단위 경계에서 끝나고 저장돼요. |
| `idle_timeout` | 5분 동안 오디오가 없었어요. |
| `max_duration` | 세션이 300분 동안 연결돼 있었어요. 새 세션을 시작하세요. |
| `disconnected` | 클라이언트가 2분이 넘도록 재개하지 않았어요. |
| `upstream_error` | 음성 인식 제공자에 장애가 났어요. |
| `internal_error` | transcript1 쪽에서 문제가 생겼어요. |
| `not_connected` | 5분 안에 한 번도 연결되지 않았어요. 과금되지 않고 `GET`으로만 볼 수 있어요. |

### 종료 코드

| 코드 | 의미 |
|---|---|
| `1000` | 정상 종료예요. 항상 `session.ended` 뒤에 와요. |
| `1011` | 예기치 않은 서버 오류예요. 세션은 서버가 복구해요. 새 토큰으로 재개하세요. |
| `1012` | 서버 재시작이에요. `reconnect` 뒤에 와요. 새 토큰으로 재개하세요. |
| `4001` | 토큰이 유효하지 않거나, 만료됐거나, 이미 사용됐어요. |
| `4004` | 세션을 찾을 수 없거나 이미 끝났어요. |
| `4008` | 프로토콜 위반이에요. 홀수 길이이거나 너무 큰 바이너리 프레임이에요. |
| `4010` | 같은 세션에 대한 더 새로운 연결이 이 연결을 대체했어요. |

API가 재시작되는 동안에는 새 WebSocket 요청에 HTTP `503`이 올 수 있어요. 잠시 뒤 다시 시도하세요.

## 재개와 인수

세션은 네트워크 끊김, API 배포, 장애가 있어도 유지돼요. 연결이 끊긴 뒤 2분 동안은 재연결을 기다려요. 그 시간이 지나면 `disconnected`로 끝나고 저장돼요.

1. `POST /v1/live/sessions/:id/token`을 호출하세요. 응답은 세션을 만들 때와 같은 `token`, `token_expires_at`, `websocket_url`이에요. 세션이 이미 끝났다면 `409 session_ended`로 실패해요.
2. 그 토큰으로 새 WebSocket을 여세요.
3. `resumed: true`인 `session.started`가 와요. `offset_ms`는 세션이 지금까지 처리한 오디오 양이에요. 그 지점부터 이어서 오디오를 보내세요. 줄 id도 이어져요.

이미 확정된 줄은 그대로 남아요. 연결이 끊긴 순간 진행 중이던 마지막 부분은 사라지므로, 오디오를 버퍼링하고 있다면 `offset_ms`부터 몇 초 분량을 다시 보내세요. 재연결 공백 시간은 과금되지 않아요.

인수도 같은 호출이에요. 서버가 아직 소켓이 열려 있다고 보더라도, 새로 들어온 유효한 연결이 이전 소켓을 대체해요. 이전 소켓은 줄이 저장된 뒤 `4010`으로 닫혀요. 동시 세션 수 제한 때문에 재개가 막히는 일은 없어요.

SDK는 이 모든 과정을 알아서 처리해요. 연결이 끊기거나 `reconnect` 이벤트가 오면 토큰을 받아 0.5초에서 8초 사이의 백오프로 최대 2분 동안 재연결해요. 끊긴 동안 보낸 오디오 중 마지막 10초 분량은 다시 보내요.

## 과금

실시간 변환은 일반 변환과 같은 분량 잔액에서 차감돼요.

- **연결 시간으로 과금해요.** 소켓이 수락된 순간부터 닫힐 때까지예요. 무음이나 일시정지한 영상도 포함돼요. 연결이 끊긴 뒤 재개하기까지의 시간은 포함되지 않아요.
- **시작한 1분 단위이고 최소 1분이에요.** 첫 1분은 첫 연결이 수락될 때, 이후 각 분은 시작될 때 과금돼요.
- **실시간보다 빠르면 오디오 길이로 과금해요.** 보낸 오디오가 연결 시간보다 길면 오디오 길이를 기준으로 과금해요.
- **요율은 API에서 읽어요.** 시작한 1분은 `rate` 분량의 잔액을 차감하고, 올림 처리해요. 요율은 보통 1이고 소수일 수도 있어요. 실시간 요율은 `GET /v1/account`의 `rates.live`, `GET /v1/live/capabilities`의 `rate`, 그리고 만든 세션의 `rate`에서 읽을 수 있어요. 세션의 요율은 만들 때 고정돼요.
- **번역은 무료예요.**
- **한 번도 연결하지 않으면 과금하지 않아요.** 5분 안에 연결되지 않은 세션은 `not_connected`로 끝나요.
- **실패하면 환불해요.** 줄을 하나도 만들기 전에 `upstream_error`나 `internal_error`로 끝난 세션은 모든 과금을 되돌려 줘요. 무음 때문에 비어 있는 세션은 과금되고, 빈 채로 저장돼요.
- **잔액이 바닥났을 때** [자동 충전](https://transcript1.com/ko/docs/billing-and-limits#auto-top-up)이 켜져 있으면, 세션이 스트리밍을 이어 가는 동안 transcript1이 저장된 카드로 결제하고 세션은 계속돼요. 자동 충전이 꺼져 있거나 일시 중지됐거나 결제에 실패하면 세션은 마무리되고 저장되며, 분 단위 경계에서 `balance_exhausted`로 끝나요. `balance_low` 경고가 먼저 오지만, 자동 충전이 켜져 있으면 오지 않아요.

사용 가능한 잔액이 현재 요율로 시작한 1분보다 적으면 세션 생성이 `402 insufficient_minutes`를 반환해요. 자동 충전이 켜져 있으면 transcript1이 먼저 충전을 시도해요. [요금제와 한도](https://transcript1.com/ko/docs/billing-and-limits#live-transcription)를 참고하세요.

## 한도

| | Free | Monthly | Annual |
|---|---|---|---|
| 동시 실시간 세션 수 | 1 | 2 | 3 |

- 세션은 `waiting`, `connected`, `disconnected`(2분의 재개 유예 안) 상태인 동안 집계돼요. 한도를 넘으면 `POST /v1/live/sessions`가 `Retry-After: 30`과 함께 `429 too_many_live_sessions`를 반환해요.
- 실시간 세션은 동시 작업 수에 포함되지 않아요.
- 세션 하나는 연결 시간으로 최대 300분까지예요. 그 뒤에는 `max_duration`으로 끝나요.
- 5분 동안 오디오가 없는 세션은 `idle_timeout`으로 끝나요. 4분이 지나면 `idle_timeout_soon`이 와요. 일시정지한 스트림도 연결 시간이 과금되고 유휴 상태로 집계되니, 정지가 길어지면 세션을 끝내세요.
- transcript1은 모든 실시간 세션의 합계에도 한도를 둬요. 전부 사용 중이면 `POST /v1/live/sessions`가 `503 live_capacity`를 반환하고, 이는 재시도할 수 있는 오류예요. `Retry-After`(30초)만큼 기다렸다가 다시 시도하세요. 이 확인은 세션을 만들 때만 하고, 세션 도중에는 하지 않아요.
- 세션 생성과 토큰 발급은 분당 요청 수에 포함돼요.
- 오디오는 원시 PCM만 가능해요: `pcm_s16le`, 모노, 8000에서 48000 Hz.

## 지원 범위

`GET /v1/live/capabilities`는 지금 실시간 변환이 지원하는 내용을 알려줘요. 클라이언트가 제공자 규칙을 하드코딩하지 않아도 되도록 만든 엔드포인트예요. 필요한 스코프는 `transcriptions:read`예요.

```json
{
  "available": true,
  "auto_language": true,
  "languages": ["en", "es"],
  "translation": {
    "targets": ["es", "de"],
    "pairs": "any",
    "requires_language": false
  },
  "diarize": true,
  "audio_formats": ["pcm_s16le"],
  "sample_rate": { "min": 8000, "max": 48000 },
  "max_session_minutes": 300,
  "rate": 1
}
```

여기서는 목록을 줄여서 보여 줘요. 실제 값은 API에서 읽으세요.

- `translation.pairs`는 `"any"`(감지한 모든 언어에서 모든 대상 언어로)이거나, `from`과 `to`가 있는 객체 목록이에요.
- `translation.requires_language`가 `true`이면 번역에 `auto` 대신 명시적인 `language`가 필요해요.
- 실시간 변환이 설정되어 있지 않으면 응답은 `rate`만 있는 `available: false`이고, 세션 생성은 `503 live_unavailable`을 반환해요.

SDK에서는 `await t1.live.capabilities()`를 쓰세요.

## 세션 조회하기

`GET /v1/live/sessions/:id`는 세션의 상태를 반환해요. 필요한 스코프는 `transcriptions:read`예요.

```bash
curl https://api.transcript1.com/v1/live/sessions/lv_8fK2Lm0PqRsTuVwXyZaBcD \
  -H "Authorization: Bearer $TRANSCRIPT1_API_KEY"
```

```json
{
  "id": "lv_8fK2Lm0PqRsTuVwXyZaBcD",
  "status": "ended",
  "end_reason": "client_ended",
  "transcription_id": "tr_3NcQvXy7BtHjKd5ErFgLmA",
  "options": {
    "language": "en",
    "translate_to": "es",
    "diarize": false,
    "audio_format": "pcm_s16le",
    "sample_rate": 16000,
    "save": true
  },
  "connected_ms": 142500,
  "audio_ms": 141900,
  "minutes_charged": 3,
  "rate": 1,
  "created_at": "2026-11-02T11:58:12Z",
  "ended_at": "2026-11-02T12:00:35Z"
}
```

`status`는 `waiting`, `connected`, `disconnected`, `ended` 중 하나예요. `end_reason`은 세션이 끝나기 전까지 `null`이에요. SDK에서는 `await t1.live.get(id)`를 쓰세요.

## 오류

요청 오류는 일반적인 [오류 응답 형식](https://transcript1.com/ko/docs/errors)을 그대로 따라요. `POST /v1/live/sessions`와 관련 엔드포인트가 반환하는 오류는 다음과 같아요.

| HTTP 상태 | `code` | 재시도 | 발생하는 경우 |
|---|---|---|---|
| 422 | `invalid_request` | 불가 | 필드가 없거나 올바르지 않아요. 예를 들어 `sample_rate`가 8000~48000 범위를 벗어났어요. |
| 422 | `unsupported_language` | 불가 | `language`가 실시간 변환에서 지원되지 않아요. |
| 422 | `unsupported_translation` | 불가 | 번역 대상 언어 또는 언어 쌍이 지원되지 않아요. |
| 402 | `insufficient_minutes` | 불가 | 사용 가능한 잔액이 현재 요율로 시작한 1분보다 적어요. |
| 403 | `email_not_verified` | 불가 | 먼저 이메일 인증을 마쳐야 해요. |
| 429 | `too_many_live_sessions` | 가능 | 요금제에서 허용하는 동시 실시간 세션이 모두 사용 중이에요. `Retry-After`만큼 기다리세요. |
| 503 | `live_capacity` | 가능 | 실시간 처리 용량이 모두 사용 중이에요. `Retry-After`(30초)만큼 기다리세요. |
| 503 | `live_unavailable` | 불가 | 지금은 실시간 변환을 사용할 수 없어요. |
| 409 | `session_ended` | 불가 | 이미 끝난 세션에 `POST /v1/live/sessions/:id/token`을 호출했어요. |

소켓에서 생기는 문제는 `error` 이벤트와 [종료 코드](https://transcript1.com/ko/docs/live#close-codes)로 전달돼요.

## 저장된 스크립트

`save: true`(기본값)이면 세션이 끝날 때 일반 변환 작업으로 저장돼요. 오디오는 저장하지 않아요.

- `transcription_id`는 세션을 만들 때 반환돼요. 상태는 세션이 진행되는 동안 `live`이고, 끝나면 `completed`예요. 한 번도 연결되지 않은 세션은 `canceled` 상태의 작업으로 남아요.
- 작업의 `source`는 `{ "platform": "live", "session_id": "lv_…" }`예요. 링크나 파일은 없어요. 대시보드에는 실시간 세션으로 표시돼요.
- 각 줄은 세그먼트 하나가 되고, 인덱스는 줄의 `id`와 같아요. 스크립트는 다른 변환 작업처럼 JSON, 텍스트, SRT, VTT로 읽을 수 있어요. [API 레퍼런스](https://transcript1.com/ko/docs/api-reference)를 참고하세요.
- 실시간 번역도 함께 저장돼요. [번역](https://transcript1.com/ko/docs/translation#read-the-translation)에서 설명하는 `translation` 파라미터로 읽을 수 있어요.
- 번역 언어는 작업이 `completed`가 된 뒤에 `POST /v1/transcriptions/:id/translations`로 나중에 더 추가할 수 있어요. [번역](https://transcript1.com/ko/docs/translation#translate-an-existing-transcript)을 참고하세요.

`save: false`이면 세션이 끝난 뒤 아무것도 남지 않아요. 변환 작업이 만들어지지 않고 `transcription_id`는 `null`이에요. 모든 줄은 소켓으로 받을 수 있고, 요금은 평소처럼 부과돼요.

## 보안

- 세션을 만들 때 받는 토큰은 일회용이고 60초 뒤에 만료돼요. URL이 아니라 WebSocket 서브프로토콜로 전달하기 때문에 액세스 로그에 남지 않아요. 유출된 토큰도 한 번 쓰거나 만료되면 쓸모가 없어요.
- API 키는 직접 관리하는 서버에서만 사용하세요. 웹 페이지나 확장 프로그램에는 API 키를 절대 넣지 마세요.
- 브라우저나 확장 프로그램에서는 사용자를 OAuth로 로그인시키고, OAuth 액세스 토큰을 `createLiveClient`의 `getAccessToken`에 전달하세요. 세션을 시작하려면 토큰에 `transcriptions:write` 스코프가 필요해요. OAuth 액세스 토큰은 1시간 뒤에 만료되며, 재개할 때마다 `getAccessToken`에서 새 토큰을 받아 와요.
- 서버가 API 키로 세션을 만들고 `websocket_url`과 `token`만 브라우저에 넘길 수도 있어요. 그러면 클라이언트가 직접 연결해요. 이 경우 클라이언트는 서버에서 새 토큰을 받지 않으면 재개할 수 없어요.
