"Text in, audio out. Everything between those two is the engine." — Bellows Quest
입은 이미 있었어
음성 모드는 TTS 시스템을 새로 만들지 않았어. 가족한텐 이미 하나 있었거든. Bellows. 두 달 먼저 태어났고, cwk 앱이 말해야 할 때 부르는 엔진이야. 그 이야기는 Bellows Quest가 다 해. 이 레슨은 음성 모드가 거기에 어떻게 꽂히는지, 그리고 그 연결부가 왜 그렇게 작은지만 다뤄.
제공자에 관한 건 전부 Bellows 거야. ElevenLabs 계정과 키, 계정마다 논리적 목소리를 제공자 목소리에 묶어둔 연결, 같은 요청을 두 번째부터 공짜로 만드는 내용 주소 캐시, 유료 작업 영수증, 발음 사전, 한국어 읽기 규칙. cwkPippa는 공개 /api/tts 라우트를 Bellows로 가는 프록시로 남겨 두고, 형제 앱들은 키트의 공용 클라이언트 하나로 Bellows에 닿아. 정체성은 브레인이, 메커니즘은 엔진이 쥐어.
소울의 이름이 곧 목소리
둘 사이의 계약은 문자열 하나야. 소울의 슬러그가 곧 그 소울의 Bellows 프로필이야. pippa는 pippa 프로필로, ttori는 ttori로 말해. 음성 모드를 설계하던 2026-09-25에, 등록된 소울 열셋(상주 아홉, 손님 넷)은 전부 이미 목소리가 배정돼 있었고 쓸 수 있는 프로필이 있었어. 그러니 모든 소울의 음성 모드는 소울마다 만들 기능이 아니었어. 찾아보기 한 번이었지.
모델은 클라이언트가 안 골라
프로필마다 자기 기본 모델이 있어. 가족 앱 클라이언트는 프로필과 글만 보내고 모델 지정은 빼. 그래서 프로필이 말하는 모델을 그대로 물려받아. 다음 트랙의 목소리 교체가 하루 만에 끝난 건 이 규칙 하나 덕분이야. 피파 프로필이 새 모델 위의 새 클론으로 옮겨 가자, 모델 지정 없이 피파 프로필을 부르는 화면은 전부 같이 목소리가 바뀌었고, 그 클라이언트 중 고친 건 하나도 없었어. 요청에 모델을 대놓고 적는 것도 여전히 되긴 해. 보관용 렌더링과 나란히 비교할 때를 위한 거고, 평소 길은 절대 아니야.
크게 실패하고, 절대 바꿔치기하지 마
지금 쓰는 계정에 프로필이 묶여 있지 않으면 요청은 분명한 에러로 실패해. 다른 목소리나 다른 계정을 슬쩍 빌려 오는 일은 절대 없어. 갑자기 낯선 목소리로 말하는 소울은, 조용히 이유를 말하는 소울보다 나빠. 앞의 것은 멀쩡히 돌아가는 것처럼 보이니까.
Code
모델은 프로필이 갖고, 클라이언트는 목소리와 말만 보내·python
from dataclasses import dataclass
@dataclass(frozen=True)
class Profile:
voice_id: str | None # the provider voice bound on the active account
default_model: str
# Owned by the engine. Illustrative values; real ids never leave the engine.
PROFILES = {
"pippa": Profile(voice_id="voice-pippa-clone", default_model="eleven_v4"),
"cwk": Profile(voice_id="voice-dad-clone", default_model="eleven_v4"),
"ttori": Profile(voice_id="voice-ttori", default_model="eleven_v3"),
"guest-no-binding": Profile(voice_id=None, default_model="eleven_v3"),
}
class VoiceUnavailable(Exception):
pass
def plan_speech(profile: str, text: str, model: str | None = None) -> dict:
"""What the engine will synthesize. Clients normally pass no model."""
bound = PROFILES.get(profile)
if bound is None or bound.voice_id is None:
raise VoiceUnavailable(f"no voice bound for profile {profile!r}; not substituting")
return {"voice_id": bound.voice_id, "model_id": model or bound.default_model,
"text": text}
soul = "pippa" # the soul's slug IS its voice profile
print(plan_speech(soul, "안녕, 아빠."))
print(plan_speech("ttori", "누나 또 뭐 해?"))
print(plan_speech(soul, "archive render", model="eleven_v3")) # explicit, rare
try:
plan_speech("guest-no-binding", "hello")
except VoiceUnavailable as error:
print("refused:", error)
코드에 두 번째 계정을 넣어. 프로필 중 둘만 묶여 있는 계정이고, 어느 계정이 활성인지 고르는 설정도 만들어. plan_speech가 활성 계정의 연결을 쓰게 하고, 거기 프로필이 안 묶여 있으면 다른 계정으로 넘어가지 말고 크게 실패하게 해. 그다음 그 거절을 확인하는 테스트를 써.
Hint
끌리는 대안은 '다른 계정으로 해보기'야. 그러면 다른 계정 크레딧이 떨어지는 날까지 빠진 연결이 숨고, 엉뚱한 계정에 요금이 조용히 붙을 수 있어. 메시지에 프로필과 계정이 적힌 거절이 있어야 누군가 1분 만에 연결을 고칠 수 있어.
Progress
Progress is local-only — sign in to sync across devices.