~13 min · speech-to-text, scribe, batch, realtime, language
Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"귀에는 속도가 두 개 있어. 상황에 맞는 쪽을 쓰고, 어느 쪽에도 언어를 짐작하게 두지 마."
가족 전체에 전사 업체는 하나
말을 글로 바꾸는 cwk 앱은 전부 전사 모델 한 계열을 써. ElevenLabs Scribe고, 계정은 가족 음성 엔진 Bellows가 쥔 그 하나야. 대부분은 Bellows를 거쳐 들어가고, 영상 보관소의 전사기는 Bellows의 키 파일을 읽어서 Scribe를 직접 불러. 어느 쪽이든 영상 보관소의 전사문, 폰의 마이크, 텔레그램 음성 메모, 음성 모드가 전부 같은 모델 버전을 타. 업체가 하나면 익힐 버릇도 한 벌, 고칠 곳도 한 군데, 지킬 키도 하나야.
배치 길
배치 길은 음성 모드보다 먼저 있던 길이야. 말 한 덩어리를 통째로 녹음해서 파일을 올리면 전사문이 돌아와. cwkPippa는 이걸 자기 /api/stt 라우트로 열어두고 호출을 Bellows로 넘기고, Bellows가 Scribe v2를 불러. 단순하고 빠짐없고, 덩치에 비해 빨라. 2026-09-25에 한국어 5초짜리와 10초짜리를 보냈더니 둘 다 0.64초쯤에 돌아왔어. 약점은 속도가 아니라 모양이야. 녹음이 끝나야 뭔가 시작되니까, 그게 언제인지 누군가 정해야 해. 음성 모드 첫 판에선 그 누군가가 완료 버튼을 누르는 아빠였어.
실시간 길
핸즈프리로 들으려면 다른 속도가 필요해. Scribe v2 Realtime은 웹소켓이야. 클라이언트는 16 kHz PCM을 잘게 잘라 input_audio_chunk 메시지로 흘려보내고, 글은 두 종류로 돌아와. partial_transcript는 지금까지 들은 걸로 모델이 추측한 문장이고, 소리가 더 들어오면 고쳐져. 음성 화면은 이걸 실시간으로 보여줘서 아빠가 자기 말이 들리고 있다는 걸 알게 해. committed_transcript는 모델이 더는 안 고칠 확정 조각이야. 음성 구간 감지 전략을 쓰면 모델이 조용한 틈이 좀 이어졌을 때 알아서 확정해 주고, 루프는 여기서 문장 하나가 끝났다는 첫 신호를 받아.
두 길은 경쟁자가 아니야. 대화는 실시간 길이 굴려. 배치 길은 여전히 입력창 마이크, 음성 메모, 워치 녹음을 받고, 트랙 2의 네 번째 레슨에서 보듯 실시간 모델이 단어를 흘렸을 만큼 긴 말은 한 번 더 통째로 받아써.
언어는 고르는 거지, 짐작하는 게 아니야
두 길 다 언어 코드를 받고, 빼먹으면 알아서 감지도 해. 빼먹지 마. 아빠는 한국어 문장 한가운데 영어 용어를 섞어 말하고, 2026-09-11에 그런 말투엔 자동 감지를 못 쓴다고 정했어. 섞인 소리 몇 초로 판정하는 감지기는 추측이 제일 어려운 바로 그 지점에서 추측하는 셈이거든. 그래서 가족 앱 화면엔 전부 마이크가 두 개야. KO와 EN. 아빠가 누른 쪽이 language_code로 Scribe까지 가. 음성 세션은 한국어로 시작하고 칩 하나로 바꿔. 제공자의 자동 감지는 옛 호출자를 위해 코드에만 남아 있고, 고를 수 있는 모드로는 절대 안 내놔.
Code
배치 전사, 그리고 실시간 소켓이 돌려주는 것·python
import os
import httpx
STT_URL = "https://api.elevenlabs.io/v1/speech-to-text"
def transcribe(path: str, language: str) -> str:
"""The batch path: one whole recording in, one transcript out.
`language` is the mic Dad pressed ("ko" or "en"). Never omit it.
"""
if language not in {"ko", "en"}:
raise ValueError("pick KO or EN; auto-detect is not offered")
with open(path, "rb") as audio:
response = httpx.post(
STT_URL,
headers={"xi-api-key": os.environ["ELEVENLABS_API_KEY"]},
data={"model_id": "scribe_v2", "language_code": language},
files={"file": (os.path.basename(path), audio, "audio/wav")},
timeout=60,
)
response.raise_for_status()
return response.json()["text"].strip()
def on_realtime_message(message: dict, segments: list[str]) -> str | None:
"""The realtime path, one server message at a time.
Returns the live caption to show, or None when nothing changed on screen.
"""
kind = message.get("message_type")
words = (message.get("text") or "").strip()
if kind == "partial_transcript":
return " ".join([*segments, words]).strip() # a guess, revised later
if kind == "committed_transcript" and words:
segments.append(words) # final for this segment
return " ".join(segments)
if kind in {"session_started", "committed_transcript_with_timestamps", "warning"}:
return None
if message.get("error"):
raise RuntimeError(f"Scribe refused: {kind}: {message['error']}")
return None
if __name__ == "__main__":
segments: list[str] = []
for msg in (
{"message_type": "partial_transcript", "text": "내일 일산"},
{"message_type": "partial_transcript", "text": "내일 일산 날씨"},
{"message_type": "committed_transcript", "text": "내일 일산 날씨 어때?"},
):
print(on_realtime_message(msg, segments))