마지막 업데이트 2026-09-16
use-mediasoup-producer.ts 브리지 지점을 보세요.POST /api/livekit/call로 토큰을 발급받으면, 웹은 AI 워커를 직접 호출하지 않고 토큰 안의
roomConfig.agents(RoomAgentDispatch)로 LiveKit 서버가 ppi-agent 워커를 소환합니다.
큰 프롬프트/설정은 토큰에 싣지 않고 Valkey에 저장 후 configRef(참조 키)만 넘깁니다.
미디어는 LiveKit Cloud SFU를 통해 오가고(mic↑ / TTS↓), 브라우저·워커는 별도 RPC·Data 채널로 턴·VAD·끼어들기를 제어합니다.
PPI에는 SFU가 둘(LiveKit Cloud = AI 음성, mediasoup = 수업 중계)이며, 아동 브라우저가 둘을 잇는 브리지입니다.
세션을 만들기 전 resolveVoiceSessionConfig()가 아동의 대화 모드와 아바타 보이스를 조합해 런타임을 정합니다.
LiveKit은 아바타에 Typecast TTS 보이스가 있을 때만 성립하고, 없으면 Realtime으로 폴백합니다.
resolveLiveKitVad().
call route는 runtime !== "livekit"이면 409 LIVEKIT_NOT_AVAILABLE 반환.
DEFAULT_CONVERSATION_MODE가 "tts" → "livekit"로 전환되어,
신규 사용자 생성 폼(user-form.tsx)의 기본 대화 모드가 LiveKit입니다. 즉 LiveKit이 예외 경로가 아니라 기본 경로이며,
Realtime/TTS는 아바타 보이스 부재 폴백 또는 명시적 설정일 때만 사용됩니다.
conversation-mode.ts · b50cefa2
UserConversationModeBadge로 분리되어
세션 카드·세션 헤더·포커스뷰에서 normalizeConversationMode(user.conversationMode) 값을 표시합니다(27c12b5c).
사용자 폼에서 음성 수업 유형을 켜거나 모니터링 유형을 바꾸면 응답 속도(VAD) 변경 확인 모달(voice-vad-change-modal.tsx)이 떠서,
취소 시 기존 응답 속도를 유지하고 수업 유형만 변경합니다(user-form-voice-vad.ts · 1a225ff6).
웹은 AI 워커를 직접 호출하지 않습니다. 발급하는 JWT 안의 roomConfig.agents(RoomAgentDispatch)에
ppi-agent를 지정해두면, 아동이 룸에 입장하는 순간 LiveKit 서버가 이름으로 워커를 소환합니다.
큰 프롬프트/설정은 토큰에 싣지 않고 Valkey에 저장한 뒤 configRef만 전달합니다.
agent_name=ppi-agent 워커를 named dispatch_publish_ppi_agent_ready() → 브라우저 RoomEvent.DataReceived 수신 →
agentReadiness.markReady(). 10초 내 미수신 시 readiness timeout.
agent-name은 웹/워커 양쪽 기본값 ppi-agent로 일치해야 매칭됩니다.
AGENT_REDIS_URL이 아니라 SSM 파라미터 /ppi/livekit/{stage}/redis-url과
AGENT_LIVEKIT_SESSION_CONFIG_REDIS_TLS를 사용하며(get_livekit_session_config_redis()),
dev는 파라미터가 없을 때만 bootstrap 값으로 1회 생성하고 기존 값은 절대 덮어쓰지 않습니다.
web 쪽 대응 모듈은 livekit-session-config-store.ts입니다.
아동 마이크는 브라우저 내 WebAudio 체인(게이트 → EQ → 컴프레서 → 리미터)을 거친 뒤 두 갈래로 나뉩니다.
하나는 SFU를 통해 AI 워커로 가는 processedTrack, 다른 하나는 진행자 모니터로 가는 relayProcessedTrack입니다.
agentInputGain만 0으로 낮춰
AI로 가는 입력만 차단하고 진행자 릴레이는 유지합니다. 다른 모드는 트랙 mute라 릴레이까지 함께 죽습니다.
outputGain은 런타임별 산식이 아니라
computeAudioOutputGain() 한 곳에서 수동 inputGain × autoGain 배수 × 리미터 ceiling으로 계산됩니다
(lib/audio-processing/output-gain-policy.ts · 8e3613ec).
autoGain은 speechFloorDb ≤ 입력레벨 < activationDb 구간에서만 targetDb까지 끌어올리고 maxGain으로 클램프합니다.
브라우저 오디오 프로파일 결정도 resolveBrowserAudioProfileForRuntime()로 합쳐져,
LiveKit이면 GET /api/livekit/config를 읽고 실패 시 코드 기본값(DEFAULT_LIVEKIT_RECOGNITION_CONFIG)으로 폴백합니다.
shouldAttachRealtimeRemoteAudio()가 false —
Realtime PeerConnection의 remote 오디오를 붙이지 않고, AI 음성은 오직 LiveKit TrackSubscribed 경로로만 재생됩니다.
livekit-audio-chain-processor.ts:196–209
연결이 서면 브라우저와 워커는 미디어와 별개로 RPC · Data 채널로 대화합니다.
브라우저는 입력/VAD/끼어들기를 RPC로 제어하고, 워커는 전사·턴 상태를 agent-event-log 토픽으로 흘려보내
브라우저가 VoiceSessionEvent로 정규화해 자막·입력 게이트를 구동합니다.
| 방향 | 채널 / 메서드 | 용도 |
|---|---|---|
| 브라우저 → 워커 | RPC agent.setInputEnabled | AI로 가는 입력 on/off (워치독 강제 종료 포함) |
| 브라우저 → 워커 | RPC agent.setVadOptions | VAD 임계 · 엔드포인팅 · 프리픽스 패딩 실시간 변경 |
| 브라우저 → 워커 | RPC agent.setAllowInterruptions | 아동 발화의 AI 끼어들기 허용 토글 |
| 브라우저 → 워커 | RPC agent.interrupt | 진행자의 AI 응답 수동 중단 |
| 브라우저 → 워커 | data · topic ppi-user-text | 텍스트 메시지 주입 (응답 지시 포함 가능) |
| 워커 → 브라우저 | data · topic agent-event-log | user_stt_segment · agent_transcript_delta/done · user_state_changed · user_turn_committed/dropped → VoiceSessionEvent |
| 워커 → 브라우저 | ActiveSpeakersChanged | AI 발화 시작/종료 감지 → onAiTalkingChange |
isTrustedLiveKitAgentData() = 참가자 kind가 AGENT이고 topic이 agent-event-log일 때만 신뢰.
원격 참가자(워커)가 아직 없으면 RPC는 pending 큐에 쌓였다가 ParticipantConnected 시 재전송됩니다.
PPI에는 서로 독립된 SFU가 둘 있습니다. AI 음성은 LiveKit Cloud SFU(관리형), 수업 미디어(아동 영상·화면공유·소리 중계)는
자체 mediasoup SFU(apps/socket)가 담당합니다. 두 SFU는 직접 통신하지 않고,
아동 브라우저가 양쪽에 동시에 참여하며 다리를 놓습니다.
aiAudioStream
mic-audio(아동 릴레이) · ai-audio(핑퐁이) · camera-video
↓ 진행자 화면공유 → 아동
onRemoteAudioStream → setAiAudioStream, 오디오 체인의 relayProcessedTrack → guestMicStream.
이 둘이 mediasoup producer ai-audio·mic-audio로 재발행됩니다 (use-mediasoup-producer.ts:634,743).
두 SFU는 서로를 알지 못하며 브라우저만 양쪽에 물려 있습니다.
ai-audio producer outbound stalled 계열 증상이 발생하는 지점입니다
(LiveKit→브라우저 수신은 정상인데 브라우저→mediasoup 재발행 트랙이 무음).
PPI-1165부터 듣기 ON/OFF의 최종 판정이 런타임 밖으로 빠졌습니다.
agent-input-policy.ts가 provider-neutral 상태 머신으로 실효 입력(effective input)을 한 번 계산하고,
LiveKit·Realtime·TTS는 그 결과만 적용합니다. LiveKit gate는 턴 lifecycle 전담으로 역할이 좁아졌습니다.
effectiveInputEnabled = listeningIntent && !aecBlocked && (!isAiSpeaking || allowInterruptions)RPC agent.setInputEnabled| 듣기 의도 | AI 발화 | 끼어들기 | AEC | 실효 입력 |
|---|---|---|---|---|
| OFF | — | — | — | false (항상) |
| ON | idle | — | 해제 | true |
| ON | 발화 중 | ON | 해제 | true |
| ON | 발화 중 | OFF | 해제 | false ← 첫 멘트 포함 |
| ON | — | — | 차단 | false |
livekit-input-gate.ts는 이제 정책을 재판정하지 않고
desired(=listening intent)와 actual(=effective)을 정책 결과로 반영하며,
speech started/stopped · turn pending/committed/dropped · stale 턴 보호 · disconnect 정리만 담당합니다.
이미 accepted된 턴은 듣기가 꺼져도 보존됩니다.
scheduleMicOnAfterFirstResponse)이
300ms AEC 지연 뒤 AI 첫 응답 도중에 입력을 열어 VAD/STT가 AI 목소리를 수집할 수 있었습니다
(Realtime 경로에만 "끼어들기 OFF && AI 발화 중" 차단이 따로 있었기 때문).
상세는 음성 입력 정책 공통화 문서.
"핑퐁이가 대답을 안 한다 / 중간에 끊겼다"를 사후 판별하기 위해, 워커가 응답 1건의 생애를 경계(boundary) 단위로 남기고 전사는 TTS를 기다리지 않고 먼저 표시합니다.
AgentResponseTraceTracker가 응답별 컨텍스트(agent_response_id · turn id)를 잡고,
종료 경계마다 boundary · elapsed_ms · completion_source · outcome · error_class를 남깁니다.RESPONSE_TERMINAL_BOUNDARIES): llm_text_stream_started/completed ·
tts_provider_stream_started · tts_provider_first_pcm · tts_provider_stream_eof/failed ·
tts_node_first_audio_frame · tts_node_audio_stream_completed · agent_state_exit · job_closing.agent_transcript_events.py의 delta/done payload를 분리해 LLM 텍스트가 나오는 즉시 브라우저로 보냅니다.
브라우저는 transcript-upsert.ts로 같은 agent_response_id를 갱신(upsert)하므로,
TTS 합성이 끝나기 전에도 진행자 화면에 AI 응답 로그가 먼저 뜹니다(fab379a6 · PPI-1139).
RESPONSE_BLOCKING_DROP_REASONS
(host_cancel_ai_response · manual_interrupt · manual_stop · superseded_by_new_speech)에 속하면,
늦게 도착한 final STT로 응답을 만들지 않습니다.mark_unresolved_turns_dropped_by_manual_interrupt()가 미해결 턴에 사유를 못박아,
진행자가 "AI 응답 취소"를 눌렀는데 잠시 뒤 답이 튀어나오는 상황을 막습니다.has_newer_turn_after) 사유는 superseded_by_new_speech로 판정됩니다.