김민호 17회기 AI 발화 토막 — TTS 공급 붕괴와 재생 버퍼 부재 원인 분석

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

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

2026-08-18 작성 · 2026-08-19 수정 · 사건 2026-08-14 15:44 (17회기) · LogRocket 2건(아동 019ffeed · 진행자 019ffed9) · roomId bcdbe421-…-2555_17 · 응답 da248c61-…:response-4 · PPI-1210 구현 완료(운영 적용 여부 미확인)

결론

신고는 "AI가 '오, 오!'만 반복한다"였지만, 로그와 파형에서는 완전한 한 문장이 재생 도중 여러 조각으로 나뉜 형태가 관측됐다. 같은 문장을 반복 생성한 것은 아니다. 텍스트 생성과 첫 발화 시각은 정상이었고, 이후 에이전트 송출 트랙의 음성 활동이 발화와 무음을 반복했다. 이 형태는 재생 중 오디오 공급 또는 처리 지연으로 출력 큐가 고갈되는 버퍼 언더런과 일치하지만, 당시 청크 도착 간격과 큐 잔량이 없어 언더런 자체는 확정할 수 없다.

확정 범위: 증상의 형태(화자 감지 13조각·파형 20조각), 텍스트·첫 발화 정상, 자기끊기 루프·에코·게스트 재생 장치·게스트↔LiveKit 전송 이상 징후 배제, 수정 전 Typecast 어댑터에 의도적인 선행 완충이 없었던 구조까지다. Typecast 청크 간격의 불규칙성, 실제 출력 큐 고갈, 공급이 밀린 주체는 미확정이다.

수정: PPI-1210의 c16a7159에서 Typecast PCM을 처음 1초 분량 모은 뒤 LiveKit AudioEmitter로 넘기도록 변경했다. 이는 유력 가설에 대한 방어적 완화이며, 근본 원인이 Typecast 공급 지연으로 확정됐다는 의미는 아니다.

기각된 초기 가설: "AI가 자기 목소리를 입력으로 잡아 스스로 말을 끊고 재시작하는 루프"는 성립하지 않는다. 근거는 아래 배제된 원인 절.

시간순 인과

아래는 모두 진행자·에이전트 시계 기준이다. 아동 브라우저 시계가 3.76초 느려서 두 로그를 나란히 볼 때 반드시 보정해야 한다(아래 시계 보정).

06:44:14.33  진행자가 아동 대신 60자 텍스트 전송 (소리 아님 · AI 입력)
06:44:14.36  아동 발화 종료 (user_state_changed talking=false)
06:44:14.9 ~ 15.4  AI 답변 텍스트 스트림 완료 — 1회, 완전, 반복 없음
                   "오냐, 이 할애비 이제 들었다. 그릇을 고르고, 바코드 찍고,
                    토핑 얹고, 마지막에 저울에 올려서 계산! 그렇게 하는 거구나!"
      ↓
06:44:16.43  음성 재생 시작  ← 시작 시각 정상 (1.52초 / 중앙값 1.57초)
      ↓
06:44:16.4 ~ 31.1  송출 트랙의 음성 활동 분절 관측 → 발화 13조각 · 무음 12회 · 총 5.2초
                   (파형 분석으로는 20조각)
      ↓
06:44:31.11  진행자가 '말하기 멈춤' — 세션 중 유일한 수동 개입, 이로써 종료
06:44:40.6   다음 응답(종료 멘트) 정상 발화

증상의 위치가 핵심이다. 생성과 첫 발화는 정상이었고 이상은 재생 도중에 나타났다. 다만 클라이언트 로그가 직접 보여 주는 것은 음성 활동의 분절이며, 이를 만든 공급 지연이나 버퍼 고갈은 추론이다.

관측 — 발화·무음 교대 구조

아동 로그의 AI_SPEAKING_START/STOP 26줄을 그대로 펼친 것이다(아동 시계).

■ 발화 (합계 9.5초) ■ 무음 (12회 · 합계 5.2초) 15:44:12.652 15:44:27.381 전체 14.7초
무음 12회가 대부분 400ms 단위로 반복된다. LiveKit 화자 감지 주기가 400ms이므로 그보다 짧은 끊김은 이 그림에 나타나지 않는다 — 파형 분석에서 20조각으로 세어진 것이 실제에 더 가깝다.

같은 세션 60개 응답 중 무음 총량 1위다. 2위와도 배 이상 벌어진다.

응답글자발화 합계무음 합계무음 횟수
15:44:14 (문제)53자9.5초5.2초12회
15:46:4853자9.5초3.6초7회
15:40:0936자5.4초3.5초5회
세션 중앙값0.8초1~2회

반복 생성이 아닌 근거 — 발화 속도가 정상이다

발화 시간 합계를 글자수로 나눠 같은 캐릭터의 다른 응답과 비교했다. 같은 값이 나오면 음성 총량이 텍스트 분량과 맞는다는 뜻이고, 말을 더 만들어내지 않았다는 근거가 된다.

응답 (모두 할아버지 캐릭터)글자발화 합계속도
15:42:52 (첫 인사)45자8.4초5.36 자/초
15:43:2760자12.1초4.95 자/초
15:44:14 (문제)53자9.5초5.59 자/초
15:44:40 (종료 멘트)25자4.6초5.41 자/초

캐릭터마다 속도가 다르므로(아동 캐릭터는 6.4~9.6 자/초) 반드시 같은 활동끼리 비교해야 한다. 문제 응답은 할아버지 캐릭터 범위 안에 있다.

파형 근거도 같은 방향이다. 별도 파형 분석에서 "짧은 소리 20개가 서로 닮지 않음(상관 0.15)"이 나왔다. 매번 첫 음절 "오(냐)"만 나오고 잘렸다면 20개가 전부 같은 소리이므로 상관이 높게 나와야 한다. 상관 0.15는 조각들이 서로 다르다는 뜻이고, 이는 한 문장이 순서대로 토막났다는 것과 일치한다.

배제된 원인

후보배제 근거 (사건 구간 16초 전체)
자기 목소리로 스스로 끊는 루프
최초 유력 가설
agent_response_started 1회, agent_state_changed 2회(발화 시작·취소 시점), 취소 전 interrupt 계열 이벤트 0건. 10여 회 끊고 재시작했다면 각각 10~20회 찍혀야 한다. participantIdentityagent-AJ_mXcVf8AvyVrV 하나로 에이전트 교체도 없다.
아동 목소리·에코 유입 입력 게이트가 40회 전부 닫힘(desiredInputEnabled=false, actualInputEnabled=false), localSpeaking27회 전부 false. 에이전트가 들을 수 있는 상태가 아니었다.
게스트↔LiveKit 네트워크 · SFU 해당 룸의 LiveKit 서버 오류 로그가 0건(같은 60초 창에서 다른 룸들은 error reading data channel 등이 정상 기록됨 — 수집 누락이 아니다). 구간 내 GUEST_NETWORK_ALERT도 없다. 이는 게스트 수신 경로의 이상 징후를 배제하는 근거이며, 에이전트↔Typecast 구간의 지연까지 배제하지는 않는다.
아동 기기 · 재생 문제 AI audio element healthpaused=false · muted=false · volume=1 · readyState=4 · currentTimeAdvanced=true. 재생 엘리먼트는 정상이었고, 무음 판정은 아동이 아니라 에이전트가 송출한 트랙의 오디오 레벨(LiveKit 서버 측 화자 감지)에서 나왔다.
영어 가드 개입 AI_ENGLISH_GUARD의 연속 일치가 최대 2회로 임계(3회) 미달. 개입하지 않았다.
문장 분할 방식 아래 별도 절.

문장 분할도 원인이 아니다

typecast_tts.py:65TTSCapabilities(streaming=False)를 선언하므로, LiveKit 기본 tts_node(voice/agent.py:506)가 이를 StreamAdapter로 감싸 문장 단위로 쪼개 순차 HTTP 요청한다(tts/stream_adapter.py:123). 실제 토크나이저(blingfire)에 문제 문장을 넣어 확인했다.

요청 1 (58자) "오냐, 이 할애비 이제 들었다. 그릇을 고르고, 바코드 찍고,
               토핑 얹고, 마지막에 저울에 올려서 계산!"
요청 2 (11자) "그렇게 하는 거구나!"        ← 요청 1이 끝나야 시작 (순차)

"들었다."의 마침표에서는 안 잘렸다 — blingfire가 !·?는 문장 끝으로 보고 .는 보지 않아서 첫 요청이 58자로 길어졌다.

요청이 2번이면 요청 사이의 틈은 최대 1번이다. 실제 무음은 12번이었으므로, 나머지는 전부 요청 1 하나를 받는 도중에 발생했다. 분할 방식으로는 설명되지 않는다.

수정 전 구조 — Typecast 단계의 선행 완충이 없었다

Typecast HTTP 응답은 WAV 컨테이너로 들어오고, 에이전트가 최대 4KB씩 읽어 WAV 헤더를 제거한 PCM을 AudioEmitter에 넘긴다. 4096은 Typecast가 반드시 4KB 패킷을 보낸다는 뜻이 아니라 HTTP 클라이언트의 읽기 크기다.

# apps/livekit-agent/typecast_tts.py (PPI-1210 수정 전)
async for chunk in resp.aiter_bytes(_STREAM_CHUNK_SIZE):   # 최대 4096 bytes씩 읽기
    payloads = parser.feed(chunk)                          # WAV → PCM
    for pcm in payloads:
        output_emitter.push(pcm)                           # 별도 선행 완충 없이 전달
항목값 (32kHz · 모노 · 16bit 기준)
HTTP 읽기 상한4,096 바이트; 전부 PCM이라고 가정하면 약 64ms 분량
요청 1 총량약 545,000 바이트 ≈ 4KB 읽기 기준 약 133회
실시간 소비량64,000 바이트/초; 4KB 기준 초당 약 16회
수정 전 Typecast 선행 버퍼0ms — PCM을 별도로 모으지 않고 AudioEmitter에 전달

“버퍼 0”의 정확한 의미: 전체 LiveKit 송출·브라우저 재생 계층에 버퍼가 전혀 없다는 뜻이 아니다. AudioEmitter는 PCM을 오디오 프레임으로 조립하고 이후 계층에도 큐와 지터 처리가 있다. 수정 전 typecast_tts.py가 공급 흔들림을 흡수하려고 의도적으로 확보한 추가 선행 버퍼가 0ms였다는 뜻이다.

일반적으로 네트워크와 서버 처리 때문에 스트림 도착 간격은 달라질 수 있지만, 이 사건에서 실제 청크 간격이 불규칙했다는 기록은 없다. 따라서 “공급 지연 → 큐 고갈 → 분절”은 관측 형태와 구조가 지지하는 가설이지 로그로 직접 측정된 사실이 아니다.

PPI-1210 수정 — 1초 분량의 선행 버퍼

c16a7159에서 apps/livekit-agent/typecast_tts.py에 고정 _PREBUFFER_MS = 1000을 추가했다. WAV에서 추출한 PCM을 먼저 모으고, 해당 포맷 기준 1초 분량에 도달하면 한 번에 AudioEmitter로 넘긴다. 기본 32kHz·모노·16bit에서는 64,000바이트(약 62.5KiB)다.

prebuffer.extend(pcm)
bytes_per_second = sample_rate * channels * bits_per_sample // 8

if len(prebuffer) >= bytes_per_second * 1000 // 1000:
    output_emitter.push(bytes(prebuffer))
    prebuffer_released = True

# 1초보다 짧은 음성은 스트림 종료 시 남은 PCM을 전달
if prebuffer:
    output_emitter.push(bytes(prebuffer))
항목구현 결과
재생 시작 조건첫 Typecast 요청에서 PCM 1초 분량을 확보하거나, 그보다 짧은 음성은 응답이 끝날 때까지 기다린 뒤 전달
시작 지연1초는 오디오 분량 기준이다. 실제 벽시계 지연은 Typecast가 그 분량을 생성·전송하는 속도에 따라 1초보다 짧거나 길 수 있다.
재생 시작 이후선행 버퍼를 한 번 해제한 뒤 도착하는 PCM은 기존처럼 즉시 AudioEmitter에 전달한다.
흡수 범위초기에 확보한 잔량 안의 일시적인 공급·처리 지연. 공급 평균이 재생보다 계속 느리거나 잔량보다 긴 정체가 발생하면 다시 고갈될 수 있다.
해결하지 않는 문제Typecast 원본 PCM 손상, WAV 파싱·프레임 변환 오류, 지속적인 워커 정체. 이 수정은 링 버퍼나 저수위 일시정지·재충전까지 구현하지 않는다.

검증: apps/livekit-agent/tests/test_typecast_tts.py에 500ms만 도착한 상태에서는 첫 프레임이 나오지 않고 임계 도달 후 전체 PCM이 보존되는 테스트, 100ms 단음성이 EOF에서 유실 없이 배출되는 테스트를 추가했다. Typecast 테스트 결과는 9 passed다.

판별법 — 아동 로그만으로 여기까지 가는 방법

재현되지 않는 단발 사건이라 사후 로그만으로 판정해야 했다. 다음 사례에 재사용할 순서다.

  1. 반복 생성 배제AI_SPEAKING_START/STOP 쌍의 ON 시간 합 ÷ 전사 글자수를 같은 캐릭터 다른 응답과 비교. 같으면 음성 총량이 텍스트와 맞으므로 반복 아님.
  2. 이상 응답 특정span − on(응답 내 무음 총량). 세션 중앙값 0.8초, 90퍼센타일 2.0초. 5초대면 확실한 이상.
  3. boundary 정체 특정 — 아래 표.

agent_response_terminal_boundary는 응답당 이름별로 한 번씩만 발행되어(agent_transcript_events.py:108) 순서가 고정이다. 그런데 클라이언트가 boundary 이름과 elapsed_ms를 버리고 eventType만 기록하므로 어느 것이 무엇인지 알 수 없다. 59개 응답에서 "첫 음성 감지보다 먼저 오는 비율"을 세어 역으로 특정했다.

순서음성보다 먼저 오는 비율정체
1~398%LLM 시작 / LLM 완료 / Typecast HTTP 헤더
493%tts_provider_first_pcm — 첫 오디오 바이트
571%tts_node_first_audio_frame — 재생 첫 프레임
60%스트림 종료

물리적으로 4·5번은 소리가 나기 전에 일어나야 하는 사건이고, 실제로 대부분 그렇다.

이 사건에서는 4·5번이 소리가 난 뒤에 도착했다. (5번 도착) − (첫 음성 감지)를 "보고 지연"이라 하면 세션 중앙값이 −0.28초(음성보다 먼저)인데 문제 응답은 +1.57초로 세션 최대이고, 무음 총량과 상관 r=0.59다. 에이전트가 자기 진단 이벤트조차 제때 못 쏠 만큼 밀려 있었다는 뜻이다.

다만 이것은 원인이 아니라 정황이다. 보고가 늦는다고 소리가 끊기지 않는다. 그리고 보고는 데이터 채널로, 소리 감지는 SFU 신호로 오는 다른 경로다. 데이터 채널만 늦었다면 같은 역전이 생기고 그 경우 무음의 원인은 다시 열린다. 사건 13초 뒤 진행자 취소 왕복이 32ms로 측정돼 채널 자체는 그 시점에 정상이었으나, 13초 전 상태는 알 수 없다.

시계 보정 — 두 로그를 나란히 보기 전에

아동 브라우저 시계가 3.76초 느렸다. 보정 없이 대조하면 인과가 뒤집혀 보인다.

진행자 로그  15:44:14.327  "[Host Message] Sent to guest…"   (송신)
아동  로그  15:44:10.581  "Received message from host:"     (수신)
             → 수신이 송신보다 3.75초 빠름 = 있을 수 없음 → 아동 시계가 느림

반대 방향(아동이 emit → 진행자가 수신)으로도 +3.78초가 나와 두 독립 측정이 일치한다. 진행자·에이전트 시계는 서로 맞으므로 진행자 기준으로 정렬한다.

미확정 — 왜 공급이 밀렸는가

후보 셋을 구분할 수 없다.

후보첫 소리 정상 설명도중 붕괴 설명보고 1.57초 지연 설명대책 방향
① Typecast 전송 지연△ 초반 정상 후 트리클이면 가능 에이전트가 한가하면 보고는 제때 감업체 문의
② 에이전트 ↔ Typecast 네트워크 같은 이유경로 점검
③ 에이전트 처리 지연 (ECS·이벤트 루프)○ 같은 정체가 발행도 밀리게 함워커 여유 확보

원인 하나로 세 관측을 모두 설명하는 것은 ③이다. 다만 ①②도 "초반 정상 → 이후 느려짐"이면 성립하고, 그 경우 보고 지연은 별개 원인이 된다. 어느 쪽도 확정이 아니다.

③의 배경으로 agent.py:294PPI_AGENT_LOAD_THRESHOLD = 0.99는 워커가 거의 꽉 찰 때까지 새 수업을 받는다는 뜻이라 여유가 얇게 운영된다. 다만 그 시각 실제 동시 작업 수·CPU는 확인할 수 없다.

관측 공백 — 확정을 막은 것

공백내용
청크 도착 기록 없음typecast_tts.py의 HTTP 읽기 루프에는 청크별 시각이나 출력 큐 잔량 로그가 없다. 첫 PCM과 스트림 종료 boundary만 있고 중간 읽기 간격은 기록되지 않는다. 두 로그 파일을 chunk/underrun/buffer/jitter/stall/pcm 등으로 훑어도 TTS 공급 간격 관련 기록은 0건(영상 재생 버퍼·mediasoup 통계 등 무관 항목 제외).
boundary 이름·경과시간 유실서버는 {boundary, elapsed_ms, outcome}을 실어 보내는데(agent_transcript_events.py:118) 클라이언트가 eventType만 남기고 버린다. 6개가 전부 동일하게 보여 통계로 역산해야 했다.
boundary dedup응답당 이름별 1회만 발행되므로 재시도와 두 번째 문장의 TTS 요청은 흔적이 없다.
에이전트 로그 자체가 없음prod livekit-agent는 ECS에서 돌아 로그가 CloudWatch로 가고 Loki에는 없다(component 라벨 값이 server 하나뿐, compose_service="agent"는 개발 스택 전용). CloudWatch는 PpiDeveloper 역할에 logs:DescribeLogGroups·logs:FilterLogEvents 권한이 아예 없어 거부된다. 원인 확정의 근본 전제다.

조치 상태와 후속 제안

상태조치효과 · 대가
구현 완료 1초 선행 버퍼 — PPI-1210 c16a7159 일시적인 공급·처리 흔들림에 대한 완충 여유를 추가한다. 요청마다 첫 출력이 늦어지고, 고정값이며, 재생 도중 저수위 재충전은 하지 않는다. 운영 환경 효과와 대화 리듬 영향은 별도 관찰이 필요하다.
미구현 agent_response_terminal_boundaryboundary·elapsed_ms를 클라이언트 로그에 포함 서버는 이미 보내는 중이라 받는 쪽 한 줄. 다음 발생 시 어느 단계가 몇 ms 걸렸는지 추정 없이 읽힌다.
미구현 청크 도착 간격 요약을 스트림 종료 시 1줄 — 개수 · 최대 갭 · p95 갭 · audio_ms / total_ms(실시간 대비 공급 배속) 배속이 1.0 근처면 공급이 실시간을 겨우 따라간 것. ①②③을 측정으로 가른다.
미구현 에이전트 로그 수집 경로 확보 근본 원인 판정의 전제. 관측성 갭으로 별도 티켓 대상.

외부 유사 사례 — DFRobot ESP32 I2S

DFRobot/DFR1154_Examples 이슈 #1은 네트워크에서 읽은 TTS 데이터를 I2S로 바로 쓰다가 공급이 비면 잡음·끊김이 발생한 사례로, 네트워크 입력과 고정 속도 출력을 버퍼로 분리해야 한다는 점에서 이번 수정과 원리가 같다.

구분DFRobot 사례PPI-1210
출력 계층ESP32 I2S·DAC 하드웨어서버의 LiveKit AudioEmitter·WebRTC 송출
선행 완충16KB부터 재생 시작포맷 기준 PCM 1초 분량부터 전달
재생 중 제어32KB 링 버퍼, 저수위 일시정지, 재충전 후 재개초기 선행 완충만 적용; 재생 시작 이후 별도 저수위 제어 없음

증거로 사용할 수 없는 이유: 해당 이슈는 ESP32 하드웨어와 OpenAI TTS를 대상으로 하며, 작성자가 분석이 AI로 생성됐고 충분히 검토하지 못했다고 명시한다. 따라서 PPI의 근본 원인을 입증하지는 않으며, 버퍼링 설계의 유사 사례로만 참고한다.

부수 발견 — 토막나면 언어가드가 조용히 죽는다

진행자 로그에서 ai_speaking_start마다 MFCC 분석 버퍼를 폐기한다.

15:44:16.429  [MFCC] discarding 26 stale events on ai_speaking_start
15:44:17.638  [MFCC] discarding 2 stale events on ai_speaking_start
   … 이후 매 조각마다 반복 …
15:44:32.658  MFCC turn buffer uploading  turnId=…response-4
              events=2 · bytes=92 · sampleCount=0 · 범위 309ms

16초 발화에서 마지막 309ms만 남았다. 토막이 잦아질수록 언어가드 검사가 무력화되는 경로이며, 이번엔 문제가 되지 않았지만 별건으로 볼 만하다.

관련 문서

조사 대상 코드: apps/livekit-agent/typecast_tts.py · apps/livekit-agent/agent_transcript_events.py · apps/livekit-agent/agent.py · apps/livekit-agent/wav_stream.py · livekit/agents/tts/stream_adapter.py(패키지) · apps/web/lib/voice-agent/livekit-client-session.ts · apps/web/entities/guest-session/model/use-ai-session.ts · apps/web/entities/guest-socket/model/use-guest-socket.ts · apps/web/entities/monitor-session/model/use-monitor-session.ts · 구현: PPI-1210 c16a7159