TTS 모드 & 합성 API — Typecast / OpenAI 코드레벨 동작 흐름

마지막 업데이트 2026-07-22

TTS 모드 speechOutput.mode /api/tts/synthesize Typecast / OpenAI buffered (스트리밍 아님) in-flight 한도 WAV 검증 + retry 1회 authz 3중 검증 백로그 P0 #4

TL;DR

Realtime 세션의 AI 음성은 두 모드다 — Realtime 음성(OpenAI Realtime이 직접 오디오 출력) vs TTS 모드(speechOutput.mode="tts": Realtime은 텍스트만 생성하고 클라이언트가 /api/tts/synthesize로 외부 TTS(Typecast/OpenAI) 음성을 합성·재생). TTS 모드는 발화와 자막이 항상 일치하는 이점이 있다.

서버 합성 흐름은 인증 → 검증(authz) → in-flight 예약(limits) → provider fetch → buffered(body 끝까지 버퍼 + 검증) → 완성 audio 반환이며 transient 실패는 같은 요청 안에서 1회만 재시도한다. provider 보호는 RPM이 아니라 in-flight 한도 + 단일 chunk 길이 제한으로 한다(chunk-heavy 정상 발화를 끊지 않으려고). 원본 설계 메모는 ppi 리포 docs/realtime-tts.md(이관 대상).

두 라우트 · 진입점

라우트구현특성
POST /api/tts/synthesizehandleTtsSynthesisRequest app/api/tts/handler.ts메인. 재시도 2회(readProviderAudio 검증). 게스트 문장 chunk 단위 재생
POST /api/tts/stream같은 handler (legacy:true)호환성 legacy. 같은 buffered handler 공유, 트래픽 0 확인 후 제거 예정
POST /api/ttsPOST app/api/tts/route.ts별도 구현. preferMember 지원(관리자 아바타 샘플 재생). 단일 시도(재시도 없음)

클라이언트는 tts-player.ts entities/guest-session/lib/tts-player.ts가 문장 경계마다 /api/tts/synthesize를 호출해 재생하고, stop/dispose 시 fetch를 abort한다. 모드 토글은 speechOutput.mode stores/use-language-guard-settings-store.ts · components/sections/speech-output-section.tsx.

합성 파이프라인 (/synthesize)

1
인증 — resolvePrincipal
handler.ts:53
child 세션 → guest, member(admin/developer) → member, 둘 다 없으면 401. 별도 TTS 토큰 없이 기존 세션 쿠키 재사용.
2
검증 — buildValidatedTtsContext
lib/tts/authz.ts:184
provider 결정(typecast/openai), voiceId, 텍스트 정규화(sanitize). 게스트는 room·activity·avatar 3중 검증: roomId 파싱 → 해당 child 매칭 → activity의 lessonIndex가 room과 일치 → avatarId === getAvatarId(persona,theme). 불일치 시 403.
3
in-flight 예약 — ttsLimiter.reserve
lib/tts/limits.ts:48
텍스트 길이(1~1900), typecast global(15)·room(2)·session(1)·adminDev(2) in-flight 검사 후 카운터 증가. 초과 시 429. typecast soft warn(10)은 경고 로그만. finally에서 release().
↓ 최대 2회 시도 · 전체 deadline 20s
4
provider 합성 — synthesizeWithRetry
handler.ts:154
typecast(/v1/text-to-speech/stream, model ssfm-v30) 또는 openai(/v1/audio/speech, gpt-4o-mini-tts, response_format:wav) fetch. API key 없으면 503 *_NOT_CONFIGURED.
5
버퍼 + 검증 — readProviderAudio
lib/tts/buffered-audio.ts:117
provider stream을 서버에서 끝까지 버퍼(5MB 상한). audio/* content-type, WAV면 RIFF/WAVE 헤더·최소 바이트·truncated 검사. 완성본만 반환.
↓ transient면 1회 재시도
6
완성 audio 반환
handler.ts:290
Content-Type + X-TTS-Provider + X-TTS-Route + Cache-Control: no-store. 실패는 responseForError로 코드별 상태 매핑.

in-flight 한도 (RPM 안 씀)

lib/tts/limits.ts. session/room RPM cap은 chunk-heavy 정상 발화를 끊을 수 있어 쓰지 않는다. 대신 동시 처리(in-flight) 버킷 + 단일 chunk 길이로 provider를 보호한다. 모두 env로 조정 가능.

버킷기본값초과 시env
typecast global15429 TYPECAST_GLOBAL_IN_FLIGHTTYPECAST_PROVIDER_GLOBAL_IN_FLIGHT
typecast soft warn10경고 로그만 (차단 X)TYPECAST_PROVIDER_SOFT_WARN_IN_FLIGHT
room2429 ROOM_IN_FLIGHTTTS_ROOM_IN_FLIGHT
session1429 SESSION_IN_FLIGHTTTS_SESSION_IN_FLIGHT
adminDev (member)2429 ADMIN_DEV_IN_FLIGHTTTS_ADMIN_DEV_IN_FLIGHT
단일 chunk 길이1900자429 INVALID_TEXT_LENGTHTTS_MAX_TEXT_LENGTH

재시도 분류 — isRetryableError

handler.ts:128. transient만 1회 더, 나머지는 즉시 실패.

재시도 O (transient)재시도 X
provider timeout · 5xx · 200인데 body 깨짐 · body read 실패 · 빈/truncated WAVvalidation · auth · rate-limit · client abort(request.signal.aborted) · *_NOT_CONFIGURED(503)

전체 deadline은 startedAt + 20s로 고정이고, 각 attempt는 남은 시간(remainingMs)을 자기 타임아웃으로 받는다. 재시도가 deadline을 넘기지 않게 분배된다.

WAV body 검증 — validateBufferedAudio

코드 맵 — 파일별 역할

파일역할
app/api/tts/handler.ts/synthesize·/stream 공유 핸들러 — 재시도 루프·deadline·로깅
app/api/tts/route.ts/api/tts — preferMember(관리자 아바타 샘플), 단일 시도
lib/tts/authz.tsbuildValidatedTtsContext — provider/voice 검증, 게스트 room·activity·avatar 3중 권한
lib/tts/limits.tsttsLimiter — in-flight 버킷 예약/해제, soft warn
lib/tts/buffered-audio.tsreadProviderAudio·validateBufferedAudio — 버퍼링 + WAV/audio 검증
lib/tts/typecast.ts · openai.tsprovider fetch 래퍼 (env: TYPECAST_API_KEY/OPENAI_API_KEY 등)
lib/tts/sanitize.tsTTS 텍스트 정규화 — 한국어 수사(살/명/시/분) 변환, 틸드/대시/미지원 문자 정리
entities/guest-session/lib/tts-player.ts클라 재생 — 문장 chunk별 fetch, stop/dispose 시 abort. 로컬 출력은 기본 Blob URL이며, iOS 덕킹 회피가 필요한 TTS 세션은 localPlaybackMode: "webrtc-loopback"으로 local WebRTC remote stream 출력 경로를 사용한다.

읽을 때 주의할 함정

1. 핸들러가 둘이다. /api/tts/synthesize·/streamhandler.ts(재시도 2회), /api/ttsroute.ts(재시도 없음·preferMember 지원)다. 같은 authz/limits를 공유하지만 재시도·권한 해석이 다르니 어느 엔드포인트인지 먼저 확인.

2. RPM cap이 없다. chunk가 많은 정상 발화를 중간에 끊지 않으려고 분당 요청 수 제한을 의도적으로 안 쓴다. provider 보호는 in-flight 한도 + 단일 chunk 길이(1900)뿐이다(limits.ts). "왜 RPM 제한이 없냐"는 의문은 이 설계 결정 때문.

3. 게스트는 자기 활동의 아바타 음성만 합성 가능. authz가 room·activity·avatar를 교차 검증한다 — avatarId !== getAvatarId(activity.persona, activity.theme)AVATAR_NOT_ALLOWED_FOR_ACTIVITY 403(authz.ts:178). 다른 아바타 음성으로 합성하려는 시도를 차단.

4. preferMember는 권한 상승이 아니다. 같은 브라우저에 게스트 쿠키가 남아 있어도 관리 화면 액션은 member로 처리하되, member 세션이 실제 admin/developer일 때만 적용된다(route.ts:42). 게스트가 이 플래그로 권한을 올릴 수는 없다.

5. 스트리밍이 아니라 buffered. provider stream을 서버에서 끝까지 모아 완성 audio만 반환한다(buffered-audio.ts:117). 단, AI 응답 전체를 한 번에 묶는 게 아니라 문장 chunk 단위 요청 흐름은 유지한다. "왜 첫 음성까지 지연이 있냐"는 chunk 단위 buffer 때문.

6. Typecast streaming WAV의 0xffffffff placeholder. 전체 길이를 모르는 streaming 응답은 RIFF size 자리에 0xffffffff를 넣는다. 이를 실제 길이와 비교하면 멀쩡한 오디오를 truncated로 오판한다 — 검증 코드가 이 값을 예외 처리한다(buffered-audio.ts:96).

7. "200인데 깨진 body"도 재시도 대상. provider가 200을 주고도 빈/truncated WAV를 보내는 경우가 있어, providerStatus === 200이어도 body 검증 실패면 retryable로 분류한다(handler.ts:135). 반대로 NOT_CONFIGURED(env 미설정)는 재시도해도 소용없어 즉시 실패.

관련 문서

OpenAI Realtime 세션 매니저 — TTS 모드에서 텍스트만 출력하는 Realtime 쪽 세션 로그 시스템 (캡션) — TTS 모드가 발화·자막 일치를 보장하는 맥락 언어 가드 설정 스토어 — speechOutput.mode 토글이 저장되는 설정 iOS 덕킹 — TTS 모드 감쇠 원인 분석 — Blob URL 출력이 통화 중 기타 미디어로 분류되는 문제와 WebRTC loopback 우회 녹음 캡션 위치 어긋남 (TTS 모드) — TTS 모드 관련 버그 분석 운영·관리·인프라 문서화 백로그 — 이 문서는 P0 #4 항목 (원본 docs/realtime-tts.md 이관)