마지막 업데이트 2026-09-12
livekit 런타임에서 "AI 발화 이벤트는 났는데 소리가 안 났다"가 신고되면 원인 구간을 좁힐 근거가 없었다. 세션 로그에는 아동측 기록만 있고, agent가 남기던 boundary 이벤트는 단계 도달 여부만 알려줬다.
두 가지 현상을 문의했고 회신으로 원인이 갈렸다. 우리가 먼저 물은 코드 검토 항목(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 배선이 전혀 없었다. |
livekit 런타임 세션 로그 1건(15,759줄)을 실측해 무엇이 없는지 확인했다.
role 필드가 15,759줄 전부 guest. 세션 로그는 아동 브라우저가 수집하므로 agent 프로세스 로그는 애초에 담기지 않는다. agent 로그는 별도 경로(Loki)로만 접근 가능하다.
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 집합은 tts_provider_stream_started / _first_pcm / _eof / _failed, tts_node_first_audio_frame, tts_node_audio_stream_completed. 전부 "거기까지 갔다"는 사실만 담고 양(量)이 없다. 첫 프레임 직후 끊긴 발화는 정상 발화와 동일한 boundary 시퀀스를 남긴다.
apps/livekit-agent/typecast_tts.py
| 메시지 | 레벨 | 의미 |
|---|---|---|
| typecast_tts_synthesis | info | 정상 합성. 수치 확인용 |
| typecast_tts_synthesis_empty_audio | warn | 200 응답인데 PCM 0바이트 |
| typecast_tts_synthesis_failed | warn | 실패 + 그 시점까지의 누적 수치 |
필드: 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
apps/livekit-agent/agent.py · HalfDuplexInputGuardAgent.tts_node
| 메시지 | 레벨 | 의미 |
|---|---|---|
| tts_node_audio_output | info | 프레임 송출 요약 |
| tts_node_audio_output_empty | warn | 프레임 0개로 종료 |
| tts_node_audio_output_incomplete | warn | cancelled / failed + error_class |
필드: frame_count, sample_total, audio_ms, first_frame_ms, sample_rate, num_channels, agent_response_id, elapsed_ms
agent.py · _audio_output_snapshot() — 계층 ② payload에 병합된다
필드: room_connection_state, audio_publication_count, audio_publications[{sid, muted, track_present}], remote_participant_count
예외는 전부 흡수해 빈 dict를 반환한다 — 진단 로그가 음성 파이프라인을 깨뜨리지 않게 한다.
apps/web/lib/voice-agent/livekit-client-session.ts
버려지던 boundary / outcome / error_class / agent_response_id / elapsed_ms를 세션 로그에 포함한다. transcript·delta 같은 대용량 본문은 제외한다(delta만 2,034건).
| 계층 ① provider | 계층 ② tts_node | 계층 ③ 트랙 | 결론 |
|---|---|---|---|
| pcm_bytes = 0 | — | — | 합성 실패 (Typecast 응답) |
| 로그 없음 | — | — | TTS 호출 자체가 없음 → LLM 단계(llm_text_stream_started/_completed) 확인 |
| OK | frame_count = 0 | — | agent 내부 (emitter/파서) |
| OK | audio_ms ≪ 텍스트 길이 | — | 부분 송출 — 기존 로그로는 보이지 않던 케이스 |
| OK | cancelled | — | 끼어들기 / 스텝 전환에 의한 중단 |
| OK | OK | count = 0 또는 muted = true | publish 실패 |
| OK | OK | OK | 아동측 → 아래 클라이언트 로그로 계속 |
| 메시지 | ctx | 판정 |
|---|---|---|
| LiveKit remote audio track subscribed | LIVEKIT_CLIENT_SESSION | 없으면 구독 실패 |
| LiveKit remote audio track unsubscribed | LIVEKIT_CLIENT_SESSION | 발화 시점에 있으면 트랙 끊김 |
| AI audio element health | AI_SESSION | paused/muted/currentTimeAdvanced/readyState |
| Audio chain health | LIVEKIT_AUDIO_CHAIN | lastLevelDb가 −90 근처면 트랙에 오디오가 안 실림 |
| Audio output suspected silent | AUDIO_PROBE | 출력 계층 무음 확진 |
| AI audio track ended | MEDIASOUP_PRODUCER | 모니터 중계 트랙 종료 |
| Audio producer outbound stalled | MEDIASOUP_PRODUCER | 아동은 들리는데 모니터만 무음 |
Audio chain health의 lastLevelDb가 정상인데 아동이 못 들으면 스피커·오디오 세션 계층, −90이면 트랙에 오디오가 안 실린 것이다.
| 순서 | 확인 | 판정 |
|---|---|---|
| 1 | format_source = fallback | WAV 헤더 파싱 실패 → 32kHz 가정. 실제 rate가 다르면 전 구간 기계음. sample_rate 값도 함께 확인 |
| 2 | 계층 ②의 sample_rate / num_channels | 발화마다 흔들리면 리샘플 경로 문제 |
| 3 | 시간 패턴 | 아래 표로 원인군 분리 |
| 발생 패턴 | 지목 |
|---|---|
| 세션 초반부터 균일하게 발생 | 합성·리샘플 계층 (1·2단계) |
| 6~16분 후 시작해 이후 지속 | iPad WebKit #241680 — agent 로그는 정상으로 나온다 |
| 마이크 활성화·블루투스 연결 직후부터 | WebKit #258864 (AudioContext ↔ HW rate 불일치) |
| 로그 | Loki ppi-livekit-agent | LogRocket | 세션 로그 |
|---|---|---|---|
| 계층 ①②③ (provider·프레임·트랙) | O | X | X |
| 계층 ④ (클라이언트 boundary/outcome) | X | O | O |
agent 로그는 LogRocket에 나타나지 않는다. LogRocket은 브라우저 세션 리플레이 도구이고 agent는 ECS 컨테이너라 접점이 없다. LiveKit 서버(ppi-livekit)와도 다른 서비스다 — 서버 로그에는 방 라이프사이클만 있고 우리 TTS 코드는 없다.
계층 ④는 logger.js가 console.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"
# 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"}
| 항목 | 적용 위치 | 내용 |
|---|---|---|
| 웹 TTS 일반 API 전환 | lib/tts/typecast.ts | endpoint /text-to-speech/stream → /text-to-speech, env 키 TYPECAST_TTS_STREAM_URL → TYPECAST_TTS_URL, 함수명 synthesizeTypecastStream → synthesizeTypecastSpeech |
| 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 변경 없음 |
스트리밍 응답은 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 |
헤더는 유효한데 값이 기대 범위 밖 — 재시도해도 안 바뀌므로 즉시 실패 |
// data(200바이트) + 뒤에 붙은 LIST chunk(11바이트) 잘못된 계산: 211 − 44 = 167 → 홀수 → wav_body_truncated → 502 수정 후 : 선언된 data size 200 사용 → 짝수 → 통과
검증: WAV 테스트 14개 통과 — trailing chunk 무시 / 선언 길이보다 짧은 payload / 홀수 payload / fmt 미지원(non-retryable) / data 앞 LIST 선행 / streaming placeholder 통과 등 경계 케이스 포함.
참고 — 녹음 합본 분석으로 확인한 것: 지지직 신고 세션(9분 16초) 합본을 분석한 결과 지속형 광대역 잡음은 0건으로 16bit 오정렬·WAV 파싱 붕괴는 배제됐고, 고역 버스트 360건 중 대부분은 치찰음 오검출(kurtosis 3~6)이었다. 분당 의심 비율이 6.6%→11%→7.2%로 평탄해 WebKit #241680의 "시간 경과 악화" 프로파일과 일치하지 않았다(00:20부터 발생). 즉 신고서의 "6~16분 후" 현상과 이 녹음의 지직은 다른 건일 수 있다. 확진은 stem 분석 필요.
| 항목 | 결과 |
|---|---|
| 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-reviewer가 validateWavChunks()의 payload 길이를 byteLength - dataOffset으로 계산하면 data chunk 뒤 LIST/JUNK가 붙은 정상 WAV를 잘림으로 오판한다고 지적 — 선언된 data size를 우선 사용하도록 수정하고 회귀 테스트를 추가했다.
같은 계열 — 아동측 관측 공백: 아동측 미디어·세션 이상 전반 — LogRocket 원인 판별용 로깅 추가 범위 (이 문서가 agent 서버측을 다루므로 상보적. 아동측 판별은 그쪽 매트릭스 참조)
지지직·기계음 선행 조사: AI 발화 "지지직" — STT 캡처 WAV 클리핑(과레벨) 분석 · PLC 기계음 오탐지 원인 분석 및 해결
LiveKit AI 발화 무음 사례: PPI-1192 iPad LiveKit AI 발화 무음 — setSinkId 사용자 제스처 수정 · LiveKit 방 전환 레이스 — 아동 마이크·AI 오디오 무음 원인 분석 · 김민호 17회기 AI 발화 토막 — TTS 공급 붕괴와 재생 버퍼 부재 (이 문서의 계층 ①②가 그 사건의 "청크 루프에 로그 0개" 공백을 메운다)
오디오 흐름 구조: Typecast → LiveKit agent → 게스트 → mediasoup → 모니터 오디오 흐름 시각화