블로그 목록
AI12분 읽기

Agora 실시간 Agent STT + 번역 구현하기

한국어로 말하는데 화면에는 곧바로 영어 자막이 뜨게 하려면 어떻게 해야 할까요? 클라이언트가 직접 음성을 인식하는 게 아니라, Agora ConvoAI 에이전트가 클라우드에서 RTC 채널에 들어와 음성을 듣고 인식과 번역 결과를 DataStream으로 돌려보내는 구조입니다. 이 글은 UDP 크기 제한 때문에 잘게 쪼개져 오는 청크를 어떻게 재조립하고, turn_id 기반 카드와 세그먼트, 1초 디바운스로 연속 발화를 자연스럽게 묶어 화면에 보여주는지까지 차근차근 짚어줍니다.

IntegrationSTTTranslationDataStreamRTC

Agora ConvoAI를 활용하면 RTC 채널의 음성을 실시간으로 텍스트 변환(STT) 하고 다른 언어로 번역까지 할 수 있습니다. 이 글에서는 ConvoAI의 STT + Translation이 어떻게 동작하는지, DataStream 청크 프로토콜과 카드 시스템의 구조를 정리합니다.

전체 데이터 흐름

[사용자 마이크] → Agora RTC 채널
                       ↓
            STT Agent (클라우드) 채널 참여
                       ↓
                 ASR → Translation
                       ↓
            DataStream (UDP 청크)으로 결과 전송
                       ↓
            클라이언트: 청크 재조립 → JSON 파싱 → UI 렌더링

클라이언트가 직접 음성 인식을 하는 게 아닙니다. 클라우드 AI 에이전트가 RTC 채널에 참여하여 음성을 듣고, 인식·번역 결과를 DataStream으로 돌려보내는 구조입니다.


핵심 구성 요소

구성 요소역할
/api/tokenRTC 토큰 생성 (agora-token 패키지)
/api/convoaiSTT REST API 프록시 (credentials 보호)
page.tsx클라이언트 — RTC 연결, 미디어, STT, UI

UID 구성

UID용도
Random 1000~100999로컬 사용자
88888ConvoAI STT Agent
55555화면 공유 (별도 RTC client)

STT Agent 시작/중지

STT 시작 — Agent Join

const requestBody = {
  name: `stt-agent-${Date.now()}`,
  preset: "v2vt_base", // Voice-to-Voice+Translation 프리셋
  properties: {
    channel: channelName,
    token: agentToken,
    agent_rtc_uid: "88888",
    remote_rtc_uids: [`${localUid}`], // 인식 대상 사용자
    idle_timeout: 300,
    advanced_features: { enable_rtm: false },
    parameters: { data_channel: "datastream" },
    asr: { language: "ko-KR" }, // 음성 인식 언어
    translation: { language: "en-US" }, // 번역 대상 언어
    tts: { enable: false }, // TTS 비활성화 (텍스트만)
  },
};

// 서버 프록시를 통해 ConvoAI API 호출
const res = await fetch("/api/convoai", {
  method: "POST",
  body: JSON.stringify({ action: "join", requestBody }),
});

v2vt_base 프리셋: Voice-to-Voice Translation의 기본 설정. TTS를 끄면 STT + 번역 텍스트만 받을 수 있습니다.

STT 중지 — Agent Leave

await fetch("/api/convoai", {
  method: "POST",
  body: JSON.stringify({ action: "leave", agentId }),
});

DataStream 청크 프로토콜

ConvoAI는 JSON 메시지를 base64 인코딩 후 여러 패킷으로 분할하여 전송합니다. Agora 데이터 스트림(sendStreamMessage)의 한도는 패킷당 최대 1KB(1024B), 채널당 초당 최대 30패킷·6KB입니다. 1KB를 초과하는 JSON 메시지는 base64 인코딩 후 여러 패킷으로 분할 전송됩니다.

청크 포맷

msgId|partIdx|partSum|base64Data
필드타입설명
msgIdstring메시지 고유 ID
partIdxnumber파트 인덱스 (1-based)
partSumnumber | "???"총 파트 수 (미확정이면 "???")
base64Datastringbase64 인코딩된 JSON 조각

3파트 메시지 예시

abc123|1|3|eyJvYmplY3QiOiJ1c2VyLnRyYW5z...   ← part 1/3
abc123|2|3|Y3JpcHRpb24iLCJ0ZXh0Ijoi7JWI...   ← part 2/3
abc123|3|3|64WV7ZWY7IS47JqUIiwiZmluYWwiOnRydWV9  ← part 3/3

재조립 과정

// 1. 청크 헤더 파싱
const msgId = chunk.slice(0, pipeIdx1);
const partIdx = parseInt(chunk.slice(pipeIdx1 + 1, pipeIdx2), 10);
const partSum = parseInt(chunk.slice(pipeIdx2 + 1, pipeIdx3), 10);
const partData = chunk.slice(pipeIdx3 + 1);

// 2. msgId로 캐시에 저장
cache[msgId].parts.push({ partIdx, content: partData });

// 3. 모든 파트가 모이면 재조립
if (cache[msgId].parts.length === partSum) {
  const fullContent = cache[msgId].parts
    .sort((a, b) => a.partIdx - b.partIdx) // 순서 정렬
    .map(p => p.content)
    .join("");
  const decoded = JSON.parse(atob(fullContent)); // base64 → JSON
}

주의: partIdx1-based입니다. 0-based 가정으로 재조립하면 undefined가 섞여 JSON 파싱이 실패합니다.


메시지 타입

user.transcription — 음성 인식 결과

{
  "object": "user.transcription",
  "text": "안녕하세요",
  "stream_id": 1234,
  "turn_id": 1,
  "final": false,
  "language": "ko-KR"
}
  • 같은 turn_id로 여러 번 전송 (partial → final)
  • text누적: "안녕""안녕하세요""안녕하세요" (final)

user.translation — 번역 결과

{
  "object": "user.translation",
  "text": "Hello",
  "transcript_text": "안녕하세요",
  "stream_id": 1234,
  "turn_id": 1,
  "final": true,
  "language": "en-US"
}
  • transcript_text: 원본 음성 인식 텍스트 (최종)
  • text: 번역된 텍스트

카드 & 세그먼트 시스템

실시간 음성을 UI에 표시하려면 "누가 언제 뭐라고 했는지"를 구조화해야 합니다. 이 데모는 칠판 시스템으로 이를 해결합니다.

구조

칠판 (activeCards)                    노트 (completedCards)
┌──────────────────────────┐         ┌──────────────────────┐
│  "1234" 칸:              │         │ (완료된 발화들)       │
│    ├ 안녕하세요   → Hello │         │                      │
│    ├ 반갑습니다   → Nice  │         │                      │
└──────────────────────────┘         └──────────────────────┘
개념비유역할
카드칠판의 칸한 사용자의 연속 발화를 하나로 묶음
세그먼트칸 안의 각 줄turn_id별 텍스트 저장
타이머1초 알람1초 동안 조용하면 칸을 노트로 옮김
clearTimeout알람 취소새 음성이 오면 알람 취소 → 같은 칸 유지

turn_id란?

ConvoAI가 "말이 끊겼다"고 판단할 때마다 숫자가 올라갑니다.

같은 문장 = 같은 turn_id (텍스트가 점진적으로 확장):

{ turn_id: 1, text: "안녕",       final: false }  ← 인식 중
{ turn_id: 1, text: "안녕하세요",  final: true  }  ← 확정

새 문장 = 새 turn_id:

{ turn_id: 1, text: "안녕하세요",  final: true }   ← 첫 문장
{ turn_id: 2, text: "반갑습니다",  final: true }   ← 새 문장

왜 세그먼트가 필요한가?

세그먼트 없이 텍스트를 하나의 변수에 저장하면:

❌ card.text = "안녕하세요"    (turn_id=1)
❌ card.text = "반갑습니다"    (turn_id=2)  ← 첫 문장이 사라짐!

세그먼트를 쓰면:

✅ card.segments[0].text = "안녕하세요"   (turn_id=1)
✅ card.segments[1].text = "반갑습니다"   (turn_id=2)
→ 화면에 둘 다 표시

묶이는 원리 — 1초 디바운스

연속된 발화가 하나의 카드로 묶이는 핵심 메커니즘:

번역 완료 → 1초 알람 시작 ("1초 후 칸을 노트로 이동")
                  ↓
새 음성 도착 → clearTimeout(알람)  ← 이 한 줄이 묶어주는 것
                  ↓
             칸이 아직 칠판에 있으니까 거기에 이어쓰기

실제 시나리오: "안녕하세요" → (0.3초 쉼) → "반갑습니다"

단계이벤트칠판 상태타이머
1transcription (turn_id=1, "안녕하세요")새 카드 생성, 세그먼트 추가없음
2translation (turn_id=1, "Hello", final)번역 추가1초 알람 시작
30.3초 후 transcription (turn_id=2, "반갑습니다")clearTimeout → 같은 카드에 세그먼트 추가알람 취소됨
4translation (turn_id=2, "Nice to meet you", final)번역 추가1초 알람 다시 시작
51초간 조용카드를 노트로 이동발동 → 완료

화면 공유 구조

Agora SDK는 하나의 RTC client에서 2개의 video track을 publish할 수 없습니다. 화면 공유를 위해 별도 RTC client(UID 55555)를 생성합니다.

Main Client (UID: random)     Screen Client (UID: 55555)
├── Audio Track (mic)          ├── Video Track (screen)
├── Video Track (camera)       └── (audio disabled)
└── DataStream listener
// 별도 client 생성 및 채널 참여
const screenClient = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
await screenClient.join(
  APP_ID,
  channelName,
  screenToken,
  SCREEN_SHARE_UID_SUFFIX,
);

// 화면 캡처 트랙 생성 및 publish
const screenTrack = await AgoraRTC.createScreenVideoTrack(
  { optimizationMode: "detail" },
  "disable", // 오디오 비활성화
);
await screenClient.publish([screenTrack]);

브라우저의 "공유 중지" 버튼 클릭 시 track-ended 이벤트로 자동 정리됩니다.


지원 언어

코드언어
ko-KR한국어
en-USEnglish
zh-CN中文
ja-JP日本語
es-ESEspañol
fr-FRFrançais
de-DEDeutsch

음성 인식 언어와 번역 대상 언어를 각각 선택할 수 있습니다. 예를 들어 한국어로 말하면서 영어 번역을 실시간으로 받을 수 있습니다.

[NEEDS VERIFICATION] Agora 공식 문서 기준 실시간 번역(Real-time Translation)은 Beta 단계로 분류되며, 지원 언어 목록과 SLA가 수시로 갱신됩니다. 위 코드 예시의 프리셋 이름(v2vt_base)과 DataStream 청크 헤더 포맷(msgId|partIdx|partSum|base64)은 실제 구현/SDK 버전에 종속적인 값이므로, 배포 전 사용 중인 ConvoAI Engine 버전의 레퍼런스로 재확인하세요.


핵심 요약

  • STT Agent가 클라우드에서 음성 인식 + 번역을 수행하고, DataStream으로 결과 전송
  • DataStream은 UDP 청크로 분할 전송되며, 클라이언트에서 재조립 후 JSON 파싱
  • 카드 시스템이 사용자별 발화를 그룹화하고, 1초 디바운스로 연속 발화를 묶음
  • 세그먼트가 turn_id별 텍스트를 보존하여 덮어쓰기 방지
  • 화면 공유는 별도 RTC client로 구현 (SDK 제약 때문)

관련 글

참고 자료

실제 데모

/agora-demo/stt

© 2026 Frank Kim. All rights reserved.