마지막 업데이트 2026-07-22
게스트 음성 세션에 transport 추상화 레이어(apps/web/lib/voice-agent/)를 도입해, 기존 브라우저→OpenAI Realtime 직결 외에 self-hosted LiveKit 서버 + Python agent(apps/livekit-agent, ECS 배포) 경로를 추가한 대형 변경이다(PR #821 → revert #873 → reapply #875 → #878, 순변경 dea36200...9fc5b813).
리뷰 시 가장 먼저 볼 지점은 두 가지: ① 기본 대화 방식이 livekit으로 바뀌었다 — conversationMode 미설정 사용자 + typecast 보이스 아바타 조합은 전부 LiveKit으로 흐른다(conversation-mode.ts의 DEFAULT_CONVERSATION_MODE), ② 세션 시작이 ppi.agent_ready(10초 타임아웃) 하드 의존이 됐다 — agent/LiveKit 서버 장애가 곧바로 세션 시작 실패다.
기존 구조는 아동(게스트) 브라우저가 OpenAI Realtime API에 WebRTC로 직결하고, 브라우저가 세션의 모든 상태(VAD, 끼어들기, TTS 재생, 전사)를 직접 관리했다. 이 방식은 iOS 덕킹·클라이언트 오디오 체인 등 기기별 이슈에 취약했다. 이번 변경은 AI 세션의 두뇌를 서버측 Python agent로 옮기는 경로를 추가한다: 브라우저는 LiveKit room에 참가자로 접속만 하고, Python agent가 같은 room에 AI 참가자로 들어와 OpenAI Realtime(LLM+STT) + Typecast TTS를 서버에서 돌린다.
User.conversationMode가 "livekit" | "tts" | "realtime" 3-way로 재정의되고 기본값은 livekit. 단 아바타에 typecast ttsVoice가 없으면 resolver가 realtime으로 폴백하므로, 실제 노출 범위는 아바타 TTS 보이스 설정 현황이 결정한다.VoiceSessionEvent로 정규화되어 Realtime과 LiveKit이 use-ai-session.ts의 동일 핸들러(handleVoiceSessionEvent)를 공유한다. 오디오 체인·Typecast sanitize·VAD 프리셋도 기존 코드를 프로파일/포팅으로 재사용.use-session-manager.ts는 runtime이 livekit이어도 "out of scope" 로그만 남기고 Realtime으로 처리 — LiveKit은 게스트 수업 경로에만 적용된다.| PR | 내용 | 머지 |
|---|---|---|
| #821 | LiveKit agent self-hosted 배포 경로 추가 (하위 PR #867 VAD 기본값 · #868 gpt-4o-transcribe · #869 staging 인프라 · #871 speaking 진단 로그 · #872 SSM secret 단일화 포함) | 7/13 18:28 |
| #873 | #821 revert | 7/13 18:51 |
| #875 | reapply (+#876 turn terminalization 보장) | 7/14 00:24 |
| #878 | agent prod 배포 워크플로우 정리 (composite action 추출) | 7/14 01:02 |
핵심 설계: 마이크 원본 트랙은 LiveKitAudioChainProcessor가 agent용 트랙과 host relay용 트랙으로 분리해서, "듣기 OFF"는 agent RPC(agent.setInputEnabled)로만 게이팅하고 SFU relay/녹음은 계속 흐르게 한다. LiveKit 경로에서 클라이언트 WebAudio 체인(gate/EQ/comp/limiter)은 전부 비활성화(LIVEKIT_BROWSER_AUDIO_PROFILE)하고 서버측 처리에 맡긴다.
transport 결정
lib/voice-agent/resolver.ts
resolveVoiceSessionConfig가 user.conversationMode(기본 livekit) + 아바타 ttsVoice 유무로 runtime 확정. typecast 보이스 없으면 realtime 폴백(avatar_livekit_tts_voice_missing)
토큰 발급 + agent 디스패치
POST /api/livekit/call
세션 소유자·lessonIndex·avatarId 3중 검증 → 서버에서 resolver 재실행(불일치 시 409) → 프롬프트+modelConfig를 JWT metadata에 실어 RoomAgentDispatch(agentName="ppi-agent")
Realtime PC 폐기 분기
entities/guest-session/model/use-ai-session.ts
isLiveKitCallDescriptor(answer)면 방금 만든 RTCPeerConnection/DataChannel을 close하고 LiveKit 경로로 전환
room 연결 + 오디오 체인
lib/voice-agent/livekit-client-session.ts
Room.connect → 마이크 publish → LiveKitAudioChainProcessor로 agent용/host relay용 트랙 분리. agent 오디오는 TrackSubscribed에서 audio element attach
agent 준비 대기
createLiveKitAgentReadinessGate
ppi.agent_ready를 10초 타임아웃으로 대기. 못 받으면 startSession throw → 세션 시작 실패 (Realtime엔 없던 실패 지점)
이벤트 정규화 → 공통 처리
lib/voice-agent/session-events.ts
agent-event-log 데이터를 liveKitDataToVoiceSessionEvent로 공통 VoiceSessionEvent로 변환 → Realtime과 동일한 handleVoiceSessionEvent에서 전사 저장/표시/auto-finish
job 수신 + config fetch
agent.py:2302 ppi_agent
dispatch name ppi-agent로 room job 수신 → 참가자 token metadata(JSON)에서 modelConfig 읽기(_fetch_model_config). metadata.sessionId가 voice_session_id
세션 구성
agent.py:769 _build_half_cascade_session
기본 half-cascade: OpenAI RealtimeModel(LLM+STT gpt-4o-transcribe/ko) + 별도 Typecast TTS 플러그인(typecast_tts.py). VAD는 Silero/ai-coustics
room join + RPC 등록
agent.py:2330-2337
session.start() → ctx.connect() → 텍스트 transport(ppi-user-text) + RPC 4종(interrupt/setAllowInterruptions/setVadOptions/setInputEnabled) 등록 → ppi.agent_ready publish
turn 게이팅 + terminalization
input_gate.py · agent.py:1431
DeferredInputGate가 발화 시작 시점의 듣기 상태를 turn에 고정, 발화 종료 후 endpointing 지연 뒤 committed/dropped로 확정(#876). drop이면 StopResponse로 LLM 응답 차단
이벤트 publish
agent.py:946 _publish_agent_event
turn/response/STT segment/interruption을 agent-event-log data channel + Loki 로그로 발행. lifecycle_trace.py가 voice_session_id/event_id 시퀀스 부여
사용자 폼: 대화 방식 3-way
components/sections/user-form.tsx
라디오 2개("설정값 따름"/"Realtime") → 3개("TTS 분리"/"LiveKit"/"Realtime"). 초기값·신규값 모두 livekit
아바타 폼: TTS 보이스 필수화
components/sections/avatar-form.tsx
Realtime voice는 선택으로 강등, typecast ttsVoice가 필수로 역전. 저장 시 Avatar.ttsVoice = { provider: "typecast", voiceId, options }
서버 검증 + DB 저장
/api/users · /api/avatars
validateVoiceAgentUserFields/AvatarFields 게이트(400) → DynamoDB. updateAvatar에 ttsVoice null 시 REMOVE 절 신설
인식게이트 전역 설정
components/sections/livekit-recognition-gate-section.tsx
신규 448줄. /api/livekit/config로 S3 단일 전역 문서(recognitionGate/서버 소음처리/브라우저 constraints/audioChain) GET/PUT — 새로 시작되는 LiveKit 활동부터 적용
세션 시작 시 소비
POST /api/livekit/call
buildLiveKitModelConfig가 user.vadPreferences.livekit + 아바타 ttsVoice + 전역 config를 합쳐 agent용 modelConfig로 직렬화
이미지 빌드/푸시
.github/actions/deploy-livekit-agent/action.yml
composite action(#878): 태그 패턴 검증 + latest 거부 → GHCR ppi-livekit-agent 푸시(non-SHA 태그 덮어쓰기 거부, buildcache)
ECS 롤링 배포
ppi-livekit-{env}-agent
task definition 이미지 교체 → register → update-service --force-new-deployment → services-stable 대기. staging/prod는 SHA 비교(check-livekit-agent-changes)로 변경시에만
web에 시크릿 주입 (SSM 단일화 #872)
/ppi/livekit/{env}/*
SSM에서 URL/API key/secret을 --with-decryption + ::add-mask::로 로드해 EC2 .env에 기록. prod는 dev 프로젝트 URL·비-wss 거부 가드 포함
worker 용량 제어
agent.py:231 _ppi_agent_load
active job 수 기반 합성 load로 worker당 최대 2세션(PPI_LIVEKIT_AGENT_MAX_JOBS_PER_WORKER). 예약 수업 기반 scheduled scaling 전제
이벤트명·payload가 livekit-client-session.ts와 agent.py 사이 1:1 계약이다. 한쪽만 바꾸면 조용히 깨진다.
| 방향 | 채널 | 이름 | 용도 |
|---|---|---|---|
| web → agent | data topic | ppi-user-text | 사용자 텍스트 입력 주입 (ppi.user_text_message) |
| web → agent | RPC | agent.setInputEnabled | 듣기 ON/OFF 게이팅 (track mute 대신) |
| web → agent | RPC | agent.setAllowInterruptions · agent.interrupt · agent.setVadOptions | 끼어들기 토글 / 수동 중단 / 런타임 VAD·endpointing 조정 |
| agent → web | data topic | agent-event-log | lifecycle 이벤트: ppi.agent_ready, user_state_changed, user_stt_segment, agent_response_started/completed, user_turn_committed/dropped, user_interruption_detected 등 |
| agent → web | LiveKit 표준 | TranscriptionReceived | assistant 최종 전사 |
| web → socket | Socket.io | GUEST_SPEAKING_START/STOP + trace | speaking 이벤트에 turnId/lifecycle trace 부착 → 서버 검증(speaking-payload.ts) 후 모니터로 relay |
| web ↔ STT 서버 | WebSocket | target_turn_id · speaker · request_id/client_request_id | external STT finalize 결과를 특정 LiveKit turn에 귀속 (Go 서버가 echo) |
| 레이어 | 파일 | 핵심 변경 |
|---|---|---|
| Python agent (신규 서비스) | apps/livekit-agent/agent.py (2,346줄) | 세션 구성(half-cascade/voice-pipeline), 이벤트 로거, RPC 4종, 텍스트 transport, load cap, turn terminalization(#876) |
input_gate.py · lifecycle_trace.py | 발화 시작 시점 듣기 상태를 turn에 고정하는 DeferredInputGate / voice_session_id·event_id 시퀀스 trace | |
typecast_tts.py · typecast_sanitize.py · wav_stream.py | Typecast 스트리밍 TTS LiveKit 플러그인 (web sanitize.ts의 Python 포팅 + 스트리밍 WAV 파서) | |
tests/ 11개 (1,449줄) | terminalization/input gate/lifecycle trace 중심 순수 로직 단위 테스트 | |
| 웹 세션 코어 | lib/voice-agent/ 16개 모듈 (신규) | resolver·conversation-mode·session-events(공통 이벤트 스키마)·livekit-client-session(1,622줄)·input-gate·audio-chain-processor·token·config·validation 등 |
entities/guest-session/model/use-ai-session.ts (+1,893) | transport 분기, LiveKit lifecycle, 통합 handleVoiceSessionEvent, 입력게이트 연동 | |
app/api/livekit/call/route.ts · config/route.ts (신규) | 토큰 발급(3중 권한 검증 + 서버측 resolver 재실행) / 전역 인식게이트 config GET·PUT | |
shared/lib/conversation-order.ts (리네임) | realtime-conversation-order를 runtime-neutral 정렬로 일반화(legacy 폴백 유지) | |
entities/guest-socket/use-guest-socket.ts · lib/external-stt-observer.ts | speaking 이벤트 trace 부착 / STT finalize turn 타깃팅(requestId echo 매칭) | |
| 관리 UI / 타입 | components/sections/ user-form·avatar-form·livekit-recognition-gate-section(신규 448줄) 등 | 대화 방식 3-way UI, TTS 보이스 필수화 역전, 전역 인식게이트 조정 화면 |
types/db/ user·avatar·session-log | ConversationMode 재정의, LiveKitVadPreferences, AvatarTtsVoiceBinding, runtime-neutral 로그 필드(conversationItemId) | |
lib/tts/ · lib/vad-presets.ts · lib/typecast-voices.ts | Typecast volume 옵션 제거 + target_lufs -25 상시, 분당 rate limit, LiveKit VAD 프리셋(#867), 보이스 65종 카탈로그 | |
| socket / STT 서버 | apps/socket/.../speaking-payload.ts (신규) · session-handlers.ts | speaking relay에 입력 검증(role/room/peer 3중 소유권) + turn 가드 상태머신 + serverSequence 진단 로그(#871) |
apps/stt/.../websocket.go · messages.go | finalize/batch_result에 target_turn_id/speaker/client_request_id 상관키 왕복(하위호환 래퍼 유지) | |
| 오디오 체인 | hooks/audio-processing-presets.ts (신규) | legacy/livekit 프로파일 분리 — LiveKit은 클라 체인 전체 OFF, 기존 경로 기본값 불변 |
hooks/use-audio-processing-chain.ts (+341) | 하드코딩 상수 → settings 주입, 비활성 노드 무해화(allpass/ratio=1) | |
lib/audio-level-observer.ts (신규) · use-mediasoup-producer.ts | RMS/peak 진단 프로브 / mic producer micEnabled 토글(기본 true) | |
| 인프라 / CI | .github/actions/deploy-livekit-agent/ + 워크플로우 5종 | GHCR 빌드(태그 가드) → ECS 롤링 배포 composite action(#878), dev/staging/prod 경로 |
terraform/branch-deploy/ | LiveKit SSM 파라미터 4변수(/ppi/ prefix validation) + 부팅 시 .env 주입(#872) | |
Dockerfile.dev.{web,socket} | builder에 python3/make/g++ (node-gyp 네이티브 빌드) |
기본 대화 방식 livekit 전환의 롤아웃 범위. normalizeConversationMode가 미설정/구값(follow)을 전부 livekit으로 해석한다. typecast 보이스가 설정된 아바타와 매칭되는 기존 사용자는 즉시 LiveKit 경로로 라우팅된다. 운영 아바타의 ttsVoice 설정 현황 기준으로 실제 몇 %가 LiveKit으로 가는지, 기존 사용자 마이그레이션 전략(일괄 realtime 고정 여부)을 확인해야 한다.
세션 시작이 ppi.agent_ready(10초) 하드 의존. Python agent 디스패치/LiveKit 서버 헬스가 세션 시작의 blocking 의존성이 됐고, 타임아웃 시 폴백 없이 startSession이 throw한다. Realtime엔 없던 실패 지점 — agent 미기동/ECS 스케일 부족 시 아동측 UX(무한 로딩/에러 표시)를 확인할 것.
"듣기 OFF"가 마이크 트랙을 더 이상 mute하지 않는다. LiveKit 경로에서는 agent RPC로만 게이팅하고 SFU relay/녹음으로는 아동 음성이 계속 흐른다(host relay 트랙 보호가 이유). 기존 Realtime은 track.enabled=false로 완전 차단했으므로 기대동작/개인정보 관점 차이가 크다. 기획 의도와 일치하는지 명시적 확인 필요.
/api/users 권한 축소로 manager 화면 조용한 기능 저하 가능. class-mgmt.tsx가 fetch("/api/users") 실패를 .catch(() => {})로 삼키는데, 이제 admin/developer 외에는 403이라 manager 롤에서 lessonTypes/대화방식 배지가 조용히 사라진다. 진행자 화면이 이 데이터에 의존하지 않는지 검증 필요.
turn↔transcript↔response 상태 머신의 복잡도. agent 쪽 input_gate.resolve_completion·terminalize_user_turn·_select_transcribed_turn_id가 여러 컬렉션(pending/terminalized/awaiting_transcript)으로 turn을 사후 연결한다. 동시 발화/빠른 재발화/STT 지연 시 turn id 오귀속 → 잘못된 drop 또는 응답 누락이 가장 큰 리스크 영역(538줄 테스트가 방증).
agent의 session.start()가 ctx.connect()보다 먼저 호출된다. RPC/텍스트 transport/agent_ready publish는 connect 이후 등록되므로, connect 완료 전 도착하는 초기 데이터/RPC 유실 여지와 브라우저의 agent_ready 대기 순서 계약이 안정적인지 확인 필요.
speaking relay 검증 강화가 기존 Realtime 경로에도 적용된다. GUEST_SPEAKING_START/STOP이 이제 role=guest + roomId/peerId 일치 + room join 상태를 모두 통과해야 relay되고, 불일치 시 조용히 드롭(socket_speaking_rejected warn만)된다. 모니터의 발화 표시가 안 뜨는 사이드 이펙트로 이어질 수 있어 실기기 확인 대상. 재접속으로 소켓이 갈리면 SpeakingRelayGuard turn 상태가 초기화되는 점도 진행자 새로고침 계열 이슈와 상호작용 가능.
Typecast volume 옵션 제거 + target_lufs -25 상시 강제. 기존에 volume으로 보이스별 음량 보정을 하던 아바타는 보정이 무시되고 -25 LUFS로 정규화된다. iPad loopback 음량 튜닝(0.25 확정) 이력과의 상호작용을 실기기에서 확인할 것.
아바타 필수 필드 역전으로 기존 데이터 편집 마찰. ttsVoice가 비어 있는 기존 아바타는 다른 필드만 고쳐 저장하려 해도 "TTS 보이스를 선택해주세요"로 막힌다. 또 realtime voice의 required 해제로 빈 문자열 저장이 가능해져, realtime 모드 아바타의 빈 voice 런타임 처리를 확인해야 한다.
STT 상관키 JSON 태그 비대칭. Go STT 서버의 inbound는 request_id, outbound는 client_request_id다. 의도된 비대칭이지만 웹 파서(consumeFinalizeTarget)가 client_request_id로 읽는 계약이 어긋나면 turn 귀속이 조용히 깨진다. 마찬가지로 client/server가 각각 resolver를 실행하는 이중 해석 구조에서 두 시점 사이 설정이 바뀌면 409(LIVEKIT_NOT_AVAILABLE)를 폴백 없이 throw한다.
untrusted 참가자의 data도 voice 이벤트로 처리된다. isTrustedLiveKitAgentData(participant kind=AGENT + topic 일치)가 false여도 debug 로그만 남기고 이벤트 변환을 계속 진행한다. room명이 세션별 유니크라 실제 노출은 낮지만 신뢰 검사가 게이트로 동작하지 않는다. agent 쪽도 data_received를 위치 기반 인자 파싱(args[0]/args[3])으로 받아 SDK 시그니처 변화에 취약하고, 텍스트 입력 처리가 fire-and-forget task라 예외가 조용히 소실될 수 있다.
남은 정리 거리. ① dev.yml은 composite action을 안 쓰고 ECS 롤아웃 로직을 인라인 중복 보유(드리프트 위험). ② 인식게이트 config는 전역 단일 문서 + last-write-wins(동시 편집 충돌 감지 없음), 진행 중 세션 미반영. ③ LiveKit 경로에서도 Realtime용 offer/PC를 만들고 즉시 폐기(불필요 비용). ④ micEnabled producer 토글이 clone 트랙 enabled를 만지므로 iPad WebKit clone 무음 계열과 겹치지 않는지 확인 권장(기본 true라 기존 경로 불변).
typecast_tts.py)으로 이식된 원본 경로. volume 옵션 제거·target_lufs 강제 변경점의 배경.audio-processing-presets.ts 프로파일로 외부화되고 LiveKit 경로에서는 전체 비활성화된 체인.target_turn_id/request_id echo)이 추가된 STT 연동의 기존 구조.speaking-payload.ts)이 추가된 socket 서버의 전체 구조.