블로그 목록
AI12분 읽기

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

AI STT Agent가 RTC 채널에서 음성을 인식하고 번역하는 구조, DataStream 청크 프로토콜, 카드 시스템의 동작 원리를 정리합니다.

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 인코딩 후 UDP 크기 제한(~1400B)에 맞게 분할하여 전송합니다.

청크 포맷

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

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


핵심 요약

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

실제 데모

/agora-demo/stt

© 2026 Frank Kim. All rights reserved.