TTS 모드 & 합성 API — Typecast / OpenAI 코드레벨 동작 흐름
마지막 업데이트 2026-07-22
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/synthesize | handleTtsSynthesisRequest app/api/tts/handler.ts | 메인. 재시도 2회(readProviderAudio 검증). 게스트 문장 chunk 단위 재생 |
| POST /api/tts/stream | 같은 handler (legacy:true) | 호환성 legacy. 같은 buffered handler 공유, 트래픽 0 확인 후 제거 예정 |
| POST /api/tts | POST 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)
resolvePrincipalguest, member(admin/developer) → member, 둘 다 없으면 401. 별도 TTS 토큰 없이 기존 세션 쿠키 재사용.buildValidatedTtsContextsanitize). 게스트는 room·activity·avatar 3중 검증: roomId 파싱 → 해당 child 매칭 → activity의 lessonIndex가 room과 일치 → avatarId === getAvatarId(persona,theme). 불일치 시 403.ttsLimiter.reservefinally에서 release().synthesizeWithRetry/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.readProviderAudioaudio/* content-type, WAV면 RIFF/WAVE 헤더·최소 바이트·truncated 검사. 완성본만 반환.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 global | 15 | 429 TYPECAST_GLOBAL_IN_FLIGHT | TYPECAST_PROVIDER_GLOBAL_IN_FLIGHT |
| typecast soft warn | 10 | 경고 로그만 (차단 X) | TYPECAST_PROVIDER_SOFT_WARN_IN_FLIGHT |
| room | 2 | 429 ROOM_IN_FLIGHT | TTS_ROOM_IN_FLIGHT |
| session | 1 | 429 SESSION_IN_FLIGHT | TTS_SESSION_IN_FLIGHT |
| adminDev (member) | 2 | 429 ADMIN_DEV_IN_FLIGHT | TTS_ADMIN_DEV_IN_FLIGHT |
| 단일 chunk 길이 | 1900자 | 429 INVALID_TEXT_LENGTH | TTS_MAX_TEXT_LENGTH |
재시도 분류 — isRetryableError
handler.ts:128. transient만 1회 더, 나머지는 즉시 실패.
| 재시도 O (transient) | 재시도 X |
|---|---|
| provider timeout · 5xx · 200인데 body 깨짐 · body read 실패 · 빈/truncated WAV | validation · auth · rate-limit · client abort(request.signal.aborted) · *_NOT_CONFIGURED(503) |
전체 deadline은 startedAt + 20s로 고정이고, 각 attempt는 남은 시간(remainingMs)을 자기 타임아웃으로 받는다. 재시도가 deadline을 넘기지 않게 분배된다.
WAV body 검증 — validateBufferedAudio
- content-type이
audio/*가 아니면non_audio_content_type(non-retryable). - WAV면
RIFF(offset 0)·WAVE(offset 8) ASCII 헤더 + 최소 바이트(128) 확인. - streaming WAV placeholder: Typecast streaming 응답은 전체 길이를 모를 때 RIFF size에
0xffffffff를 넣는다. 이 값은 실제 body 길이와 비교하지 않는다(buffered-audio.ts:96). - 그 외엔 declared size + 8 > 실제 길이면
wav_body_truncated(retryable). 5MB 초과는audio_body_too_large(non-retryable).
코드 맵 — 파일별 역할
| 파일 | 역할 |
|---|---|
| app/api/tts/handler.ts | /synthesize·/stream 공유 핸들러 — 재시도 루프·deadline·로깅 |
| app/api/tts/route.ts | /api/tts — preferMember(관리자 아바타 샘플), 단일 시도 |
| lib/tts/authz.ts | buildValidatedTtsContext — provider/voice 검증, 게스트 room·activity·avatar 3중 권한 |
| lib/tts/limits.ts | ttsLimiter — in-flight 버킷 예약/해제, soft warn |
| lib/tts/buffered-audio.ts | readProviderAudio·validateBufferedAudio — 버퍼링 + WAV/audio 검증 |
| lib/tts/typecast.ts · openai.ts | provider fetch 래퍼 (env: TYPECAST_API_KEY/OPENAI_API_KEY 등) |
| lib/tts/sanitize.ts | TTS 텍스트 정규화 — 한국어 수사(살/명/시/분) 변환, 틸드/대시/미지원 문자 정리 |
| 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·/stream은 handler.ts(재시도 2회), /api/tts는 route.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 미설정)는 재시도해도 소용없어 즉시 실패.