마지막 업데이트 2026-07-22
onended 이벤트가 발생하지 않으면서 playing=true로 고착됐고,
이후 도착한 모든 AI 발화가 재생 큐에 갇혀 27~41초간 완전 침묵 → 진행자 강제 재접속(kick)으로 이어졌다.
response.create → Response completed)은 매번 1초 미만 정상interrupted 미처리)| 세션 | 회기 | 기기 | 스톨 발생 | 침묵 시간 | 종료 방식 |
|---|---|---|---|---|---|
세션 A (f78bbe0c…_4) | 4회기 | iPad (내장 마이크/전면 카메라) | 00:31:19 / 00:32:49 (UTC, 2회) | 27초 / 41초 | 강제 재접속(kick) 2회 |
세션 B (4d728a81…_16) | 16회기 | iPad (내장 마이크/전면 카메라) | 01:03:55 (UTC, 1회) | 35초 | 강제 재접속(kick) 후 재입장 실패 반복 |
두 세션 모두 스톨 전후로 기기·네트워크 불안정 정황 동반: 킥 직후 NETWORK_QUALITY "외부·서버 모두 도달 실패(인터넷 전체 다운)" 진단,
critical NetworkAlert(cameraVideo·cameraAudio·socket), 재입장 시 PermissionDenied 반복.
AI 응답 텍스트는 스트리밍 델타로 들어와 문장 경계에서 청크로 잘려 큐에 쌓인다
(appendDelta → sentence_boundary / max_buffer / finalize).
각 청크는 병렬로 합성(fetch)·디코딩되지만, 재생은 큐 맨 앞부터 한 번에 하나다.
source.onended → finishCurrentPlayback → playing=false → tryPlayNext() 하나뿐이다.
디코딩 완료 시에도 tryPlayNext()가 호출되지만 첫 줄 if (this.playing) return에서 전부 튕긴다.
타이머·재생 위치 폴링·buffer.duration 데드라인 같은 보조 경로는 없다. onended가 유실되는 순간 큐 전체가 stop()(킥에 의한 세션 정리)까지 영구 정지한다.
진행자 메시지에 대한 AI 응답(턴 d3173c00, 10글자+7글자 청크 2개)에서 발생.
막대 길이는 실제 로그 타임스탬프 비율.
| 시각 (UTC) | 이벤트 | 판정 |
|---|---|---|
| 00:31:07.646 | Sent response.create → 00:31:08.366 Response completed | OpenAI 정상 (0.7초) |
| 00:31:08.835 | 청크1 fetch 응답 (534ms) → 디코딩(10ms) → 재생 시작 | 공급 정상 |
| 00:31:09.552 ~ 19.390 | 전 로거 10초 완전 공백 후 청크1 onended (재생 10.5초) | 런타임 정지 전조 |
| 00:31:19.391 | 청크2 재생 시작 → onended 영영 미발생 | 스톨 시작 |
| 00:31:20 / 32 / 42 | 진행자 메시지 3건 → AI 응답 3턴 생성(0.7~0.9초), 청크 6개 fetch·디코딩 성공 — 재생 0회 | 큐 블로킹 (27초 침묵) |
| 00:31:46.846 | Guest force kicked → Stopping session으로 큐 폐기. 직후 "인터넷 전체 다운" 진단 | 스톨 종료 |
{{듣기실패1차}}(00:33:01), -다시 말해줘(00:33:18) 응답 모두 수신만 되고 재생 안 됨 → 41초 침묵 → 킥{{듣기실패1차}}, {{재접속안내}} 응답 수신·디코딩 성공, 재생 0회 → 35초 침묵 → 킥(01:04:30) → critical NetworkAlert(01:04:38) → 재입장 시 권한 오류 반복으로 수업 중단| 청크 | 재생 시작 | onended | 소요 | 기대치 |
|---|---|---|---|---|
| A#1 청크1 (10글자) | 00:31:08.846 | 00:31:19.390 | 10.5초 (지연) | ~2.5초 |
| A#1 청크2 (7글자) | 00:31:19.391 | 미발생 | 킥까지 27초 | ~2초 |
| A#2 청크1 (14글자) | 00:32:39.014 | 00:32:49.153 | 10.1초 (지연) | ~3.5초 |
| A#2 청크2 (14글자) | 00:32:49.504 | 미발생 | 킥까지 41초 | ~3.5초 |
| B 청크1 (12글자) | 01:03:55.632 | 미발생 | 킥까지 35초 | ~3초 |
확인 방법: LogRocket 내보내기 JSON의 TTS chunk playback started / ended를
diagnostic의 traceId + chunkIndex로 짝 맞추고 타임스탬프 차를 계산.
"미발생"은 세션 정리(Stopping session) 시점까지 짝이 없음 + 그 사이 다른 로거는 정상 간격으로 계속 찍힘(로그 수집 유실 배제) + 콜백을 무효화하는 generation 증가는 킥 시점에만 발생함으로 교차 검증.
기대치 기준은 같은 세션 정상 청크들의 글자당 0.2~0.4초 비율.
스톨의 트리거는 기기(iPad) 런타임/오디오 정지지만(전 로거 동시 공백, 콜백 동시 해빙, 네트워크 진단이 근거), 몇 초짜리 환경 장애가 영구 블로킹이 된 것은 코드 구조 때문이다.
| # | 취약점 | 위치 |
|---|---|---|
| 1 | 단일 실패 지점 — 청크 진행 경로가 source.onended 하나뿐. 유실 시 playing=true 고착 | tts-player.ts:413-417 |
| 2 | watchdog 부재 — 비디오에는 있는 백스톱이 TTS엔 없음. buffer.duration으로 예상 길이를 아는데도 데드라인 강제 진행 없음 | 전체 |
| 3 | 복구 시도가 도달 불가능 — 유일한 ctx.resume()이 tryPlayNext() 안에 있는데 재생 중엔 첫 줄에서 리턴 → 정작 멈췄을 때 resume 0회 실행 | tts-player.ts:398,405 |
| 4 | iOS interrupted 미처리 — 체크는 "suspended"만, statechange 리스너 없음. resume도 await 없이 fire-and-forget 후 즉시 source.start() | tts-player.ts:405 |
// tts-player.ts:397 — 재생 중이면 어떤 호출도 무시 (resume 라인까지 못 감)
private tryPlayNext(): void {
if (this.disposed || this.playing) return;
...
if (ctx.state === "suspended") ctx.resume().catch(() => {}); // fire-and-forget
...
source.onended = () => { ... this.finishCurrentPlayback(gen, metadata); }; // 유일한 진행 경로
source.start(); // resume 완료를 기다리지 않음
}
AudioContext.state가 로그에 찍히지 않기 때문. 픽스는 하나로 수렴한다.
[TTS_PLAYER] TTS chunk playback started 이후 같은 traceId·chunkIndex의 playback ended가 수십 초간 없음 → 스톨 확정TTS chunk queued / response received / audio decoded가 계속 쌓임 → 공급 정상 + 재생 블로킹 (TTS API 문제 아님)started→ended 간격이 clientTextLength × 0.3초 대비 3배 이상 → 전조 (런타임 정지)Received message from host: {{듣기실패1차}}(현장 무음 방증), Guest force kicked(스톨 종료점), NETWORK_QUALITY/NetworkAlert(기기 환경)| # | 수정 | 효과 |
|---|---|---|
| 1 | 재생 watchdog — 재생 시작 시 buffer.duration + 여유(예: 3초) 타이머를 걸고, onended 미도착 시 강제 finishCurrentPlayback + 경고 로그 | onended 유실 시 몇 초 내 자동 복구. "지연" 변종과 "미발생" 변종 모두 커버 |
| 2 | AudioContext statechange 리스너 — suspended/interrupted 진입·이탈을 로그로 남기고, 이탈 시 resume + 진행 점검 | 재발 시 로그 한 줄로 판별 가능 + 인터럽션 자동 복귀 |
| 3 | start 전 상태 보장 — resume()을 await하거나 state === "running" 확인 후 source.start() | "suspended 상태로 시작 → 즉사" 변종 차단 |
관련 문서: iOS 덕킹 TTS/Realtime 모드 감쇠폭 차이 원인분석, 아동070 1회기 아동 소리 안 들림 원인분석, 영상 종료 자동전환 미발생 Watchdog 분석 (비디오 쪽 watchdog 선례)