TTS Blob src 교체 AbortError — AI 발화 말끊김 세션 로그 원인 분석 원인 확인
마지막 업데이트 2026-07-29
결론 — 범인부터 공개한다
HTMLAudioElement.play()가 완료되기 전에 새 Blob URL을 같은 audio 엘리먼트의 src에 설정했다. 브라우저는 이전 재생 요청을 취소하고 AbortError를 반환한다.청크 도착 간격이 일정하지 않은 것은 이 현상을 촉발하는 조건일 뿐, 근본 문제는 새 청크를 즉시 같은 엘리먼트에 덮어쓰는 재생 경합 처리다. 이 로그만으로 TTS 공급 서버가 비정상적으로 빠르게 청크를 보냈다고 단정할 수는 없다.
수사 일지 ① — 사건 발생과 직접 증거
prod 세션(6-019f7ee8…/0/2dfd0dd5…)에서 AI TTS 말끝이 잘린다는 신고가 들어왔다. 세션 로그를 열자 브라우저가 스스로 범행을 진술한 기록이 두 번 남아 있었다 — AbortError, 그것도 62초 간격으로 같은 문구가 반복됐다. 일회성 사고가 아니라 패턴이라는 뜻이다.
| 시각 | 컴포넌트 | 로그와 판정 |
|---|---|---|
| 19:00:50.822 KST 10:00:50.822 UTC | TTS_PLAYER | AbortError: The play() request was interrupted by a new load request.새 로드가 기존 재생을 중단했다는 브라우저의 직접 진단. |
| 19:01:52.549 KST 10:01:52.549 UTC | TTS_PLAYER | 동일한 AbortError 재발. 일회성 사용자 권한/네트워크 실패가 아니라 재생 경합 패턴. |
[2026-07-20T10:00:50.822Z] [WARN] [TTS_PLAYER]
TTS local audio element playback failed
{
"name": "AbortError",
"message": "The play() request was interrupted by a new load request. https://goo.gl/LdLk22"
}
수사 일지 ② — 범행 수법: 코드 경로와 발생 조건
브라우저 진단문("The play() request was interrupted by a new load request")이 가리키는 곳은 명확했다 — 재생 중인 엘리먼트에 새 로드를 덮어쓰는 코드. TTS 재생 경로를 따라가 보니 구현이 로그와 정확히 같은 그림을 그리고 있었다.
apps/web/entities/guest-session/lib/tts-player.ts의 playBlobAudioElement()는 재생 중인 엘리먼트에도 즉시 다음 순서로 동작한다.
audio.srcObject = null;
audio.src = objectUrl; // 새 TTS 청크의 Blob URL
void audio.play().catch(...);
audio.src = Aplay() 시작ended 전audio.src = B로 즉시 교체AbortError 및 말끝 절단 가능코드 주석도 이전 src를 새 Blob으로 교체하면 이전 재생이 즉시 중단된다고 명시한다. 따라서 로그와 구현이 동일한 원인을 가리킨다.
수사 일지 ③ — 용의선상의 다른 신호들 (별건 분리)
같은 세션 로그에는 다른 이상 신호도 여럿 찍혀 있었다. 이들을 말끊김의 원인으로 엮으면 수사가 흐려진다 — 하나씩 대조해 AbortError와의 관계를 판정하고, 별도 조사 대상과 무관 신호를 분리했다.
| 신호 | 로그 시각 | 의미 / 관계 |
|---|---|---|
ai-audio outbound stalled | 19:12:08 | producer는 live·unmuted·transport connected이나 5초 표본의 bytes/packets/energy delta가 모두 0. 원격 진행자에게 전달되는 AI 오디오가 멈출 수 있는 별도 경로이며, 위의 local Blob AbortError와는 별도 조사 대상. |
AUDIO_PROBE suspected silent | 세션 중 5회 | AudioContext가 running인데 hardware clock이 전진하지 않았다. 기기 출력 파이프라인 이상 후보이나, TTS AbortError의 직접 원인은 아니다. |
| noise reduction mismatch | 19:08:49 | 원하는 far_field와 실제 null 불일치. 아동 입력 잡음/인식 품질 후보이며 TTS 출력 말끊김의 원인은 아니다. |
| External STT duplicate skipped | 강제 퇴장 직후 77회/6.619초, 재차 9회/0.706초 | 종료 뒤 잔여 STT 콜백 또는 동일·무음 청크 정황. TTS 재생 중단의 근거로 사용하지 않는다. |
수사 일지 ④ — 검거 계획: 재생 큐로 단일 소비자 보장
범행 수법이 "재생 중 src 즉시 교체"로 확정된 이상, 대응은 새 청크가 현재 재생을 건드리지 못하게 만드는 것이다. 청크 도착 간격을 조절하는 쪽(공급 측)이 아니라 소비 측에서 순서를 보장한다.
src를 변경하지 말고 FIFO 큐에 넣는다. 현재 청크의 ended 후에만 다음 Blob URL을 설정한다.- 청크 도착: Blob URL을 재생 큐에 추가하고, 재생 중이면 반환한다.
- 단일 async 재생 루프: 큐 맨 앞을
audio.src에 설정 →play()→ended를 기다린 뒤 다음 항목으로 진행한다. - 정상 완료 뒤에만 해당 Blob URL을 revoke한다.
- 세션 종료·사용자 끼어들기처럼 의도적으로 중단할 때만
pause(),src초기화 및 큐 비우기를 수행한다. - 의도적 취소에는 세션 세대/generation을 남겨 예상된 AbortError로 분류하고, 그 외 AbortError는 재생 경합 오류로 유지한다.
청크 간 무음까지 최소화해야 하는 요구가 확인되면, 후속 대안으로 Web Audio의 예약 재생을 검토할 수 있다. 그러나 현재 직접 증거에 대한 최소 대응은 단일 소비자 FIFO 큐다.
검증 포인트 — 재현·검증 기준
- 청크 A 재생 도중 청크 B를 의도적으로 주입해도 A의
ended전audio.src가 바뀌지 않는다. - 정상 연속 발화에서
TTS local audio element playback failed / AbortError가 0건이다. - 의도적 취소 시에는 이전 음성이 즉시 중단되고, 취소 사유·generation이 로그에 남는다.
- 원격 출력 검증은
ai-audiooutbound RTP delta를 별도로 확인한다. local TTS 재생 성공만으로 SFU 송신 정상까지 판단하지 않는다.