PPI-1227 — LiveKit agent AI 미발화·기계음 3계층 진단 로그 추가 (Typecast 회신 반영) 분석로깅 추가

마지막 업데이트 2026-09-12

쉬운 설명 한 장 보기 · 사전지식 없이 읽는 요약

2026-08-24 브랜치 PPI-1227 PR #1011 apps/livekit-agent · apps/web

결론 — 잡으려는 범인은 "관측 공백"이다

livekit 런타임에서 "AI 발화 이벤트는 났는데 소리가 안 났다"가 신고되면 원인 구간을 좁힐 근거가 없었다. 세션 로그에는 아동측 기록만 있고, agent가 남기던 boundary 이벤트는 단계 도달 여부만 알려줬다.

이전: tts_node_first_audio_frame이 찍혔으면 "첫 프레임은 나왔다"까지만 안다. 1프레임(10ms) 후 끊긴 발화와 3초짜리 정상 발화가 같은 로그를 남긴다.
이후: provider(Typecast HTTP) → tts_node(프레임) → 룸·트랙(publish) 3계층에 수치를 남겨, 끊긴 지점을 로그만으로 지목한다. 아동측 로그와 맞물려 "agent까지는 정상 / 아동측에서 소실"을 가른다.
함께 확인된 것: Typecast 공식 회신으로 WAV 헤더 0xFFFFFFFF placeholder는 지지직의 원인이 아님이 배제됐고, 요청 간 피치·음색 변동은 seed 미지정에 따른 의도된 동작으로 확인됐다.

1. 배경 — Typecast 문의 회신 요약

두 가지 현상을 문의했고 회신으로 원인이 갈렸다. 우리가 먼저 물은 코드 검토 항목(1·2·4)과 증상 자체를 겨냥한 항목(3·5)이 섞여 있다.

회신 항목내용우리에게 주는 결론
1. 0xFFFFFFFF 유지 가능? 스트리밍 응답의 RIFF/data size는 최종 길이를 모르므로 0xFFFFFFFF로 고정되며 스트림 종료 후에도 갱신되지 않음(의도된 동작). 실측 결과 Chrome(FFmpeg)은 "크기 미상 → EOF까지", Safari/iOS(CoreAudio)는 실제 파일 길이로 클램프 — 사이즈를 수정한 파일과 디코드 결과가 비트 단위 동일. 용의자 배제. 헤더 placeholder는 지지직 원인이 아니다.
2. size 재작성 필요? 필수는 아니나 권장. <audio> duration 정상화·구형 파서 방어 효과. 덤으로 우리가 공유한 코드의 truncation 검증(declaredRiffSize !== 0xffffffff && ...)이 스트리밍 응답에선 항상 첫 조건이 false라 동작하지 않는다고 지적. 죽은 검증 코드 확인. (전체길이−44)가 0보다 크고 2의 배수인지, fmt가 기대값인지로 교체 권장.
3. 32k→48k 호환성? decodeAudioData()는 사양상 AudioContext rate로 자동 리샘플하며 알려진 문제 없음. 다만 증상 프로파일(6~16분 후 발생·이후 지속·재접속 후 잔존·voice 무관)은 재생 경로 특성에 가깝다며 WebKit 미해결 이슈 지목 — #241680(WebRTC 수 분 이상 → robotic/crackling), #258864(라우트 변경 후 AudioContext↔HW rate 불일치가 이후 컨텍스트까지 전파). 로컬 loopback은 PCM 직재생이 아니라 Opus 왕복 + iOS playAndRecord 전환을 거쳐 장시간 열화 구간. 리샘플 배제. 원인 확정을 위해 우리 쪽 확인 3건 요청(아래 4절).
4. 전체 버퍼링이면 일반 API? 권장. 응답 전체를 버퍼링하는 구조면 Streaming API의 이점(첫 바이트 지연 단축)이 없으므로 정확한 사이즈 필드를 가진 POST /v1/text-to-speech 사용 권장. /api/tts에만 해당. agent는 실제로 스트리밍하므로 전환 대상 아님.
5. seed 미지정 시 변동? 달라질 수 있으며 의도된 동작. seed 미지정 시 요청마다 새 랜덤 시드로 샘플링되어 같은 voice·텍스트도 피치/음색/운율이 달라짐. 문장별 동일 seed(uint32≥1) 지정 권장(완전한 결정성은 보장 안 됨), 가능하면 여러 문장을 한 요청에. 신고 ①(피치·음색 변동)의 원인 확정. 우리 코드에 seed 배선이 전혀 없었다.
회신의 성격을 정확히 읽어야 한다. Typecast는 자체 재현에 실패했고 "기기 특성으로 인한 문제로 추측", "재생 경로 쪽 특성에 가깝다"고 썼다. 확정된 진단이 아니다 — 그래서 원본 데이터를 요청했다.

2. 관측 공백 — 세션 로그 실측

livekit 런타임 세션 로그 1건(15,759줄)을 실측해 무엇이 없는지 확인했다.

실측 ① — agent 로그가 아예 없다

role 필드가 15,759줄 전부 guest. 세션 로그는 아동 브라우저가 수집하므로 agent 프로세스 로그는 애초에 담기지 않는다. agent 로그는 별도 경로(Loki)로만 접근 가능하다.

실측 ② — 받은 agent 이벤트의 payload를 버린다

agent는 데이터 채널로 boundary·outcome·elapsed_ms를 실어 보내는데, 클라이언트 로그(LiveKit data received 4,396건)에 남는 필드는 4개뿐이었다.

// 실제 기록된 레코드 — 수치가 전부 사라진다
{ "topic": "agent-event-log", "kind": 0,
  "participantIdentity": "agent-AJ_nhYRU79Egg7U",
  "eventType": "agent_response_terminal_boundary" }
//                          ↑ 어느 boundary인지, 성공인지 실패인지 알 수 없다

agent가 보낸 이벤트 종류는 agent_response_terminal_boundary 480건, agent_response_started/_completed 각 80건 등으로 충분히 많았다. 정보가 없던 게 아니라 기록 단계에서 버려졌다.

실측 ③ — boundary는 도달 여부만 알려준다

기존 boundary 집합은 tts_provider_stream_started / _first_pcm / _eof / _failed, tts_node_first_audio_frame, tts_node_audio_stream_completed. 전부 "거기까지 갔다"는 사실만 담고 양(量)이 없다. 첫 프레임 직후 끊긴 발화는 정상 발화와 동일한 boundary 시퀀스를 남긴다.

3. 추가한 3계층 진단 로그

계층 ① provider — Typecast HTTP 응답의 실체

apps/livekit-agent/typecast_tts.py

메시지레벨의미
typecast_tts_synthesisinfo정상 합성. 수치 확인용
typecast_tts_synthesis_empty_audiowarn200 응답인데 PCM 0바이트
typecast_tts_synthesis_failedwarn실패 + 그 시점까지의 누적 수치

필드: http_status, content_type, pcm_bytes, audio_ms, pcm_chunks, http_chunks, first_pcm_ms, elapsed_ms, sample_rate, num_channels, bits_per_sample, format_source, text_length, has_seed, voice_id, model, error_class

format_source가 기계음 조사의 1번 지표다. fallback이면 WAV 헤더 파싱이 실패해 _STREAM_FALLBACK_FORMAT(32kHz/mono/16bit)을 가정한 상태다. 실제 rate가 다르면 전 구간이 기계음이 된다.

계층 ② tts_node — 실제로 몇 ms가 흘렀나

apps/livekit-agent/agent.py · HalfDuplexInputGuardAgent.tts_node

메시지레벨의미
tts_node_audio_outputinfo프레임 송출 요약
tts_node_audio_output_emptywarn프레임 0개로 종료
tts_node_audio_output_incompletewarncancelled / failed + error_class

필드: frame_count, sample_total, audio_ms, first_frame_ms, sample_rate, num_channels, agent_response_id, elapsed_ms

부수 발견 — 완전 무음은 원래 잡혔다. 오디오가 0이면 LiveKit AudioEmitterAPIError: no audio frames were pushed를 스스로 던진다. 즉 진짜 안 보였던 건 부분 송출 (예: 1프레임 후 종료)이고, 이 계층이 그것을 잡는다. 다만 예외만으로는 provider가 200을 줬는지 알 수 없어 계층 ①의 수치가 함께 필요하다.

계층 ③ 룸·트랙 — publish는 됐나

agent.py · _audio_output_snapshot() — 계층 ② payload에 병합된다

필드: room_connection_state, audio_publication_count, audio_publications[{sid, muted, track_present}], remote_participant_count

예외는 전부 흡수해 빈 dict를 반환한다 — 진단 로그가 음성 파이프라인을 깨뜨리지 않게 한다.

계층 ④(보완) 클라이언트 payload 보존

apps/web/lib/voice-agent/livekit-client-session.ts

버려지던 boundary / outcome / error_class / agent_response_id / elapsed_ms를 세션 로그에 포함한다. transcript·delta 같은 대용량 본문은 제외한다(delta만 2,034건).

4. 판별 매트릭스

A. AI 미발화 — 소리가 아예 안 남

계층 ① provider계층 ② tts_node계층 ③ 트랙결론
pcm_bytes = 0합성 실패 (Typecast 응답)
로그 없음TTS 호출 자체가 없음 → LLM 단계(llm_text_stream_started/_completed) 확인
OKframe_count = 0agent 내부 (emitter/파서)
OKaudio_ms ≪ 텍스트 길이부분 송출 — 기존 로그로는 보이지 않던 케이스
OKcancelled끼어들기 / 스텝 전환에 의한 중단
OKOKcount = 0
또는 muted = true
publish 실패
OKOKOK아동측 → 아래 클라이언트 로그로 계속

A-3단계. 아동측 로그 (세션 로그 · LogRocket)

메시지ctx판정
LiveKit remote audio track subscribedLIVEKIT_CLIENT_SESSION없으면 구독 실패
LiveKit remote audio track unsubscribedLIVEKIT_CLIENT_SESSION발화 시점에 있으면 트랙 끊김
AI audio element healthAI_SESSIONpaused/muted/currentTimeAdvanced/readyState
Audio chain healthLIVEKIT_AUDIO_CHAINlastLevelDb가 −90 근처면 트랙에 오디오가 안 실림
Audio output suspected silentAUDIO_PROBE출력 계층 무음 확진
AI audio track endedMEDIASOUP_PRODUCER모니터 중계 트랙 종료
Audio producer outbound stalledMEDIASOUP_PRODUCER아동은 들리는데 모니터만 무음

Audio chain healthlastLevelDb가 정상인데 아동이 못 들으면 스피커·오디오 세션 계층, −90이면 트랙에 오디오가 안 실린 것이다.

B. 기계음 · 지지직

순서확인판정
1format_source = fallbackWAV 헤더 파싱 실패 → 32kHz 가정. 실제 rate가 다르면 전 구간 기계음. sample_rate 값도 함께 확인
2계층 ②의 sample_rate / num_channels발화마다 흔들리면 리샘플 경로 문제
3시간 패턴아래 표로 원인군 분리
발생 패턴지목
세션 초반부터 균일하게 발생합성·리샘플 계층 (1·2단계)
6~16분 후 시작해 이후 지속iPad WebKit #241680agent 로그는 정상으로 나온다
마이크 활성화·블루투스 연결 직후부터WebKit #258864 (AudioContext ↔ HW rate 불일치)
3단계에서 agent 로그가 전부 정상이면 agent 코드로는 해결할 수 없다. 확진은 녹음 stem 분석으로 넘어간다. 주의: 합본 녹음에 지직이 있어도 재생 출력단 문제를 배제하지 못한다 — 녹음 경로가 아동 디코드 → clone → SFU Opus 재인코딩 → 서버 믹싱 → mp3 6단계를 거치기 때문이다.

5. 조회 위치

로그Loki
ppi-livekit-agent
LogRocket세션 로그
계층 ①②③ (provider·프레임·트랙)OXX
계층 ④ (클라이언트 boundary/outcome)XOO

agent 로그는 LogRocket에 나타나지 않는다. LogRocket은 브라우저 세션 리플레이 도구이고 agent는 ECS 컨테이너라 접점이 없다. LiveKit 서버(ppi-livekit)와도 다른 서비스다 — 서버 로그에는 방 라이프사이클만 있고 우리 TTS 코드는 없다.

계층 ④는 logger.jsconsole.debug + logSink 양쪽으로 보내므로 LogRocket 콘솔과 세션 .jsonl.gz에 모두 남는다.

# 발화별 오디오 송출량
{service_name="ppi-livekit-agent"} |= "tts_node_audio_output"

# 발화 이벤트는 났는데 프레임 0
{service_name="ppi-livekit-agent"} |= "tts_node_audio_output_empty"

# Typecast가 200 주고도 PCM 0
{service_name="ppi-livekit-agent"} |= "typecast_tts_synthesis_empty_audio"

# 샘플레이트 오판 (기계음 후보)
{service_name="ppi-livekit-agent"} |= "format_source" |= "fallback"
라벨 키는 미검증이다. 리포지토리에 Loki/promtail 설정이 없어(인프라 외부 관리) 코드로 확정할 수 없다. 기록상 service_name="ppi-livekit"(서버)과 service=ppi-livekit-agent(agent)가 섞여 있다. 확실한 방법은 라벨을 고정하지 않는 것 — {service_name=~".*livekit.*"} |= "tts_node_audio_output".

6. 함정 — payload가 조용히 사라질 수 있었다

발견: agent.py_configure_logging()LIVEKIT_TEST_LOG_FORMAT != "json"이면 logging.basicConfig()만 호출하고 반환한다. 기본 포맷터는 record.payload읽지 않는다 → Loki에 메시지 이름만 뜨고 수치가 전부 사라진다.
악화 요인: .github/actions/deploy-livekit-agent/action.yml은 기존 task definition을 복사해 STAGE·redis 항목만 갱신한다. LIVEKIT_TEST_LOG_FORMAT은 손대지 않으므로 환경마다 값이 다를 수 있고 코드로 확인할 수 없다.
대응: 비-json 경로에 PayloadTextFormatter를 붙여 message 뒤에 payload를 JSON으로 덧붙인다. 두 모드 모두에서 수치가 남고, payload 없는 기존 로그는 그대로 출력된다.
# json 모드
{"timestamp":"...","level":"INFO","logger":"typecast-tts",
 "message":"typecast_tts_synthesis","pcm_bytes":64000,"audio_ms":1000}

# 비-json 모드 (이번에 보강)
INFO:typecast-tts:typecast_tts_synthesis {"pcm_bytes": 64000, "audio_ms": 1000, "format_source": "header"}

7. 함께 적용한 Typecast 회신 대응 (같은 PR)

항목적용 위치내용
웹 TTS 일반 API 전환 lib/tts/typecast.ts endpoint /text-to-speech/stream/text-to-speech, env 키 TYPECAST_TTS_STREAM_URLTYPECAST_TTS_URL, 함수명 synthesizeTypecastStreamsynthesizeTypecastSpeech
WAV 검증 복구 lib/tts/buffered-audio.ts scanWavChunks()로 data chunk 오프셋 탐색(44 고정 가정 제거), payload가 blockAlign 배수인지, fmt가 기대 범위인지 검증. wav_format_unsupported는 non-retryable
seed 고정 지원 typecast-options.ts · livekit-token.ts · avatar-form.tsx 아바타 옵션으로 seed 저장 → LiveKit tts_extra와 TTS payload 양쪽 전달. agent 플러그인은 이미 seed를 받으므로 Python 변경 없음

WAV 끝 길이 검증 — 죽어 있던 검사가 처음으로 동작한다

스트리밍 응답은 RIFF size가 항상 0xFFFFFFFF였다. 그래서 기존 잘림 검증의 첫 조건이 영구히 false였고, 응답이 중간에 끊겨 와도 그대로 통과했다. 일반 API로 바꾸면서 정확한 size가 오므로 두 겹으로 검사한다.

검사실패 reason의미
① RIFF size
declaredSize + 8 > byteLength
wav_body_truncated
retryable
원래 있던 검사. 0xFFFFFFFF placeholder면 건너뛰고 ②에 의존
② data 선언 길이
declaredDataSize > availableBytes
wav_body_truncated
retryable
헤더가 주장하는 오디오 길이가 실제 받은 바이트보다 큼 = 잘림
③ 샘플 정렬
payloadLength % blockAlign
wav_body_truncated
retryable
16bit 샘플이 반쪽으로 잘리면 재생 시 잡음이 된다
④ fmt 스펙
채널 1~2 / 8k~48kHz / 16bit
wav_format_unsupported
non-retryable
헤더는 유효한데 값이 기대 범위 밖 — 재시도해도 안 바뀌므로 즉시 실패
sampleRate를 32000 고정으로 검증하지 않았다. 같은 검증기를 OpenAI TTS도 타므로 (openai.tsresponse_format: "wav") rate를 고정하면 정상 응답이 전부 502가 된다. 범위로만 확인한다.
리뷰에서 잡힌 함정 — trailing chunk를 오디오로 오인. payload 길이를 byteLength − dataOffset으로 계산하면, 일반 API가 data 뒤에 붙여 보낼 수 있는 LIST/JUNK 메타데이터까지 오디오로 세어 정상 WAV를 502로 거부한다.
// data(200바이트) + 뒤에 붙은 LIST chunk(11바이트)
잘못된 계산: 21144 = 167  → 홀수 → wav_body_truncated → 502
수정 후    : 선언된 data size 200 사용 → 짝수 → 통과
대응: 선언된 data size가 있으면 그 길이만 오디오로 보고, 0xFFFFFFFF placeholder일 때만 파일 끝까지를 payload로 취급한다. data chunk 위치도 오프셋 44로 가정하지 않고 scanWavChunks()로 탐색해 fmtdata 사이에 LIST가 끼어도 안전하다.

검증: WAV 테스트 14개 통과 — trailing chunk 무시 / 선언 길이보다 짧은 payload / 홀수 payload / fmt 미지원(non-retryable) / dataLIST 선행 / streaming placeholder 통과 등 경계 케이스 포함.

seed는 배선만 없었다. typecast_tts.pyagent.pytts_extra.seed를 읽을 준비가 끝나 있었고, 웹의 buildTypecastAgentTtsExtra()가 넣지 않아 항상 None이 전달됐다. 즉 지금까지 모든 발화가 매 요청 랜덤 시드로 합성됐다.
웹 경로 전환은 LiveKit 모드에 적용되지 않는다. agent는 _PREBUFFER_MS(1초) 이후 4KB 단위로 실제 스트리밍하므로 일반 API로 바꾸면 발화 지연이 늘어난다. Typecast 4번 답변의 전제("전체를 버퍼링하는 구조라면")가 agent에는 성립하지 않는다. wav_stream.py0xFFFFFFFF를 이미 올바르게 처리하므로 헤더 관련 조치는 agent에 불필요하다.

8. 적용 범위와 한계 (정직하게)

참고 — 녹음 합본 분석으로 확인한 것: 지지직 신고 세션(9분 16초) 합본을 분석한 결과 지속형 광대역 잡음은 0건으로 16bit 오정렬·WAV 파싱 붕괴는 배제됐고, 고역 버스트 360건 중 대부분은 치찰음 오검출(kurtosis 3~6)이었다. 분당 의심 비율이 6.6%→11%→7.2%로 평탄해 WebKit #241680의 "시간 경과 악화" 프로파일과 일치하지 않았다(00:20부터 발생). 즉 신고서의 "6~16분 후" 현상과 이 녹음의 지직은 다른 건일 수 있다. 확진은 stem 분석 필요.

9. 검증

항목결과
agent 단위 테스트118개 통과 (기존 112 + 신규 6)
tsc --noEmit통과 (남은 1건 host-disconnected.png는 기존 오류)
tts 테스트 6개 파일전부 통과 (seed 3 / options 2 / WAV 4 / livekit-token 2 신규)
로그 출력json / 비-json 두 모드 확인
실기기·실 API미검증 — 8절 참조

부수 수정: typecast.test.ts의 기존 3개 케이스가 develop에서 이미 실패 상태였다 (target_lufs: -25 기본값 추가 후 테스트 미갱신). 함께 고쳤다.

코드 리뷰 반영: ppi-codex-reviewervalidateWavChunks()의 payload 길이를 byteLength - dataOffset으로 계산하면 data chunk 뒤 LIST/JUNK가 붙은 정상 WAV를 잘림으로 오판한다고 지적 — 선언된 data size를 우선 사용하도록 수정하고 회귀 테스트를 추가했다.

관련 문서