본문 바로가기
C.W.K.
Lesson 03 of 05 · published

답 마이크와 골라 둔 귀

~14 min · 음성 인식, 멱등성, scribe, 전달

Level 0첫 단어
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

대화의 나머지 반쪽

카드를 보여주면 답이 와. 점원이 되묻거나, 값을 말하거나, 아빠가 원한 게 다 떨어졌다고 하거나. Spark 대화 화면의 입력칸 밑엔 답 마이크가 있어. 세 부분으로 돼 있어. 상대 답의 언어라는 뜻의 Their reply in 캡션, 언어 칩 한 줄, 그리고 넓은 Record their reply 버튼. 상대 답을 녹음하라는 버튼이야. 녹음하는 동안엔 멈추고 전하라는 Stop and relay로 바뀌고, 옆에 소리 크기 막대랑 초를 세는 숫자가 붙어.

칩은 카드를 쓴 그 언어, 곧 장면 언어로 시작하고, 아빠가 녹음하기 전에 바꿀 수 있어. 소리를 듣고 무슨 언어인지 알아내는 건 없어. 이건 설계에서 정한 규칙이야. 외국어 답을 듣는 언어는 기본이 장면 언어고, 아빠가 바꿀 수 있고, 절대 몰래 알아내지 않아. 웹 클라이언트의 언어 목록에도 같은 메모가 있어. 언어는 짐작하는 게 아니라 고르는 거고, 이건 받아쓰기를 두고 아빠가 예전에 내린 판단에서 왔다는 메모야. 고를 수 있는 건 10개야. 스페인어, 카탈루냐어, 바스크어, 갈리시아어부터 일본어, 중국어까지.

폰은 키트의 VoiceClipRecorder로 녹음하고, 웹은 브라우저의 MediaRecorder를 써. 둘 다 엔진에 다 끝난 클립 하나를 넘겨.

녹음부터 지켜

클립은 PUT /api/moments/{id}/replies/{media_id}로 올라가. 이 경로에서 중요한 선택이 셋 있어.

  • 녹음 id는 클라이언트가 폰에서 만든 ULID고, 클라이언트 요청 id는 쿼리 값으로 같이 가. 같은 업로드가 두 번 오면 경로는 앞서 만든 턴을 찾아서 created: false로 돌려줘. 엔진 테스트는 답 하나를 두 번 보내고, Bellows한테 받아쓰기를 딱 한 번만 시켰는지 확인해.
  • 소리는 무엇보다 먼저 recording-other 역할의 미디어로 순간에 저장돼. 나중에 받아쓰기가 실패해도 녹음은 코퍼스에 남아.
  • language는 꼭 있어야 하는 쿼리 값이고, es나 pt처럼 소문자 두세 글자여야 해. 없으면 처리 함수가 돌기도 전에 422로 거절되니까 아무것도 저장되지 않아. 이걸 확인하는 테스트 이름이 test_the_listening_language_is_never_guessed야. 듣는 언어는 절대 짐작하지 않는다는 뜻이지.

Scribe는 골라 둔 언어로 들어

이제 전달 턴이 뒤에서 돌아(두 번째 코드 블록). 저장된 소리를 읽고 SparkVoice.transcribe(첫 번째 코드 블록)를 불러. 이 함수는 파일을 Bellows의 /api/stt로 보내면서 language_code에 아빠가 고른 값을 넣어. 제공자 계정은 Bellows가 갖고 있고, 기본 음성 인식 모델은 scribe_v2야. 형제 엔진 중에 Bellows로 받아쓰기를 하는 건 Spark가 처음이라, 이 호출은 아직 Spark 안에 있는 코드야. 모듈 설명엔 두 번째 엔진이 필요해지면 키트로 옮긴다고 적혀 있어.

transcribe를 언어 없이 부르는 경우는 딱 하나, 트랙 6의 쇼 카드 통역기야. 거긴 테이블의 누구든 말할 수 있어서, 들은 언어를 Scribe가 알려줘. 답 마이크는 거기 해당 안 돼. 언제나 언어 코드를 넘겨.

실패 두 가지는 이유를 달고 턴을 끝내. Bellows에 닿지 못하거나 Bellows가 거절하면, 턴은 Bellows가 준 메시지를 달고 실패해. 받아쓴 글이 비어 있으면 턴은 녹음에서 아무것도 안 들렸다는 nothing was heard in the recording을 달고 실패하고, 두뇌한테는 묻지도 않아. 이 경우를 다루는 엔진 테스트는 가짜 피파가 턴을 하나도 안 받았는지 확인해. 빈 받아쓰기는 해석할 거리가 아니야.

들은 걸 남기고, 그걸 전해

글자가 있는 받아쓰기는 먼저 heard 결과로 기록돼. 거기엔 날것 그대로의 글, 들은 언어, 녹음을 가리키는 about이 담겨. 이어서 전달 턴이 순간 스레드에서 나한테 물어. 메시지는 상대가 이렇게 답했다는 Spark: they said this back.이고, 전달용 덧붙임(세 번째 코드 블록)이 같이 와. 덧붙임은 받아쓰기가 틀릴 수 있으니 뜻으로 읽으라고 일러. 나는 내가 알아들은 상대 말과 한국어를 담은 relay, 아빠가 틀 수 있는 answer 2~4개, 그리고 대화에 곧 필요할 표현을 돌려줘.

결과쓴 쪽지어낸 예시
heard엔진. Scribe 받아쓰기를 고치지 않고con tarjeta o en efectivo
relay나. 바로잡은 상대 말, 한국어, 중요한 점¿Con tarjeta o en efectivo? / 카드로 할까요, 현금으로 할까요?
answer나. 아빠가 틀 수 있는 대답과 쓸 자리Con tarjeta, por favor. / 카드로 할게요.
expression나. 다음에 나올 법한 말¿Me da el recibo? / 영수증 주시겠어요?

두 글을 다 남기는 게 중요해. heard는 기계가 받은 거고, relay는 내가 그걸로 만든 거야. 둘이 다르면 아빠는 내가 받아쓰기를 어디서 고쳤는지 볼 수 있어. 폰에선 들은 줄이 들은 언어를 달고 내 전달 위에 작고 흐리게 놓여 있어. 증거가 내 해석 뒤에 숨는 일은 없어.

원칙: 누가 해석하기 전에 도구가 돌려준 걸 먼저 저장해. 고치기 전 버전이 남아 있어야 고친 걸 확인할 수 있어.

대답은 바로 쓰는 카드야

폰에서 대답 하나는 재생 버튼, 원문, 한국어, 발음, 그리고 use 줄이 든 한 줄이야. 대답은 종류부터 상대를 향한 말이라서, 탭하면 아빠가 직접 만든 카드와 똑같이 큰 카드가 열려. 대답엔 미리 기록된 목소리가 없어. 첫 탭 때 장면 언어로 /api/tts/ensure를 거쳐 Bellows한테 묻고 파일을 틀어. 그 뒤론 캐시가 답해. 그렇게 고리가 닫혀. 아빠가 말하고, 상대가 답하고, 아빠 손엔 벌써 쓸 만한 대답 몇 개가 있어. 하나하나 탭 한 번이면 보여주거나 들려줄 수 있어.

Scribe가 실시간 루프 안에서 흘러가며 일하는 모습은 Voice Mode Quest의 귀 트랙에서 다뤄. Spark의 답 마이크는 일부러 더 단순해. 클립 하나, 사람이 고른 언어 하나, 남겨 두는 받아쓰기 하나.

Code

cwkSpark, backend/voice.py: 골라 둔 언어로 Bellows를 거치는 받아쓰기 (원문 그대로, 48-75행)·python
    async def transcribe(
        self, audio: bytes, *, filename: str, content_type: str, language: str | None
    ) -> dict[str, Any]:
        """What the other person said, in the language Dad chose. ``None`` is
        the show card's interpreter only (cwkPippa todo #609, Dad's ruling
        2026-10-23): anyone at the table may speak, so Scribe names the
        language and the answer carries it (``language`` may come back
        ``None`` when Scribe names none)."""
        if language is not None and not _LANGUAGE.fullmatch(language):
            raise VoiceError("the listening language must be a code such as es or ca")
        try:
            async with httpx.AsyncClient(timeout=120.0) as client:
                response = await client.post(
                    f"{self.base}/api/stt",
                    params={"language_code": language} if language else None,
                    files={"file": (filename, audio, content_type)},
                )
        except httpx.HTTPError as exc:
            raise VoiceError(f"cwkBellows unreachable ({self.base})") from exc
        if response.status_code != 200:
            try:
                detail = response.json().get("detail")
            except ValueError:
                detail = response.text[:200]
            raise VoiceError(f"cwkBellows could not transcribe (HTTP {response.status_code}: {detail})")
        data = response.json()
        heard_in = language or (str(data.get("language_code") or "").strip().lower() or None)
        return {"text": str(data.get("text") or "").strip(), "language": heard_in, "model": data.get("model")}
cwkSpark, backend/turns.py: 받아쓰고, 이유를 달아 실패하고, 들은 걸 남기는 전달 턴 (원문 그대로, 1119-1146행)·python
            audio = await asyncio.to_thread(path.read_bytes)
            listening = str(turn.get("language") or "")
            try:
                heard = await self.voice.transcribe(
                    audio,
                    filename=media["filename"],
                    content_type=str(media.get("content_type") or "application/octet-stream"),
                    language=listening,
                )
            except VoiceError as exc:
                await self._fail(turn, str(exc))
                return
            if not heard["text"]:
                await self._fail(turn, "nothing was heard in the recording")
                return
            await self._write(
                self.corpus.add_result,
                moment["id"],
                turn_id=turn["id"],
                target_language=moment.get("target_language") or "ko",
                fields={
                    "kind": "heard",
                    "source": heard["text"],
                    "language": listening,
                    "about": media["media_id"],
                    "source_key": source_key(heard["text"]),
                },
            )
cwkSpark, backend/turns.py: 전달용 덧붙임 (원문 그대로, 383-407행)·python
def relay_addendum(moment: dict[str, Any], listening: str) -> str:
    scene = language_name(moment.get("scene_language")) or language_name(listening) or "their language"
    heard = language_name(listening) or listening
    return "\n".join(
        [
            _spark_frame(moment),
            "",
            "### This turn: what they said back",
            "",
            f"The person Dad is talking to just answered. The words below were transcribed",
            f"from {heard}; the transcript can be wrong, so read it for meaning. Tell Dad",
            "what they said, say what matters in it, and give him answers he can play.",
            "",
            "Return ONE fenced block whose info string is `spark`:",
            "",
            "```spark",
            '{"type": "relay", "source": "<what they said, corrected>", "translation": "<in Korean>", "note": "<what matters in it>"}',
            '{"type": "answer", "source": "<a reply in ' + scene + '>", "translation": "<Korean>", "pronunciation": "<in Hangul>", "use": "<when it fits>"}',
            '{"type": "expression", "source": "...", "translation": "...", "pronunciation": "...", "use": "..."}',
            "```",
            "",
            "Give two to four answers, then any expressions the conversation is likely to",
            "need next. Outside the block, a sentence or two to Dad at most.",
        ]
    )

External links

Exercise

두 사람 사이에서 쓰는 번역 도구에서 상대 답을 받는 엔드포인트를 만들어봐. 클라이언트는 클립을 녹음하고, 클라이언트가 만든 id와 꼭 있어야 하는 두세 글자 언어 코드를 붙여 올려. 서버는 소리부터 저장하고, 손에 잡히는 음성 인식 서비스로(가짜 함수도 돼) 받아써. 받아쓴 글이 비면 언어 모델은 부르지 말고 분명한 이유를 달아 실패해. 그리고 날것의 받아쓰기를 모델이 바로잡은 전달보다 먼저 별도 기록으로 남겨. 같은 업로드는 한 번만 받아쓰는지, 언어가 없으면 거절되는지 테스트해.
Hint
언어 검사는 경로 시그니처에 넣어. 그래야 뭘 저장하기 전에 요청이 거절돼. 클라이언트 요청 id부터 찾아보고, 같은 요청이 다시 오면 앞서 만든 턴을 돌려줘. 테스트에선 가짜 받아쓰기 함수와 가짜 모델의 호출 횟수를 세. 한 번 불렸다, 한 번도 안 불렸다, 이 두 확인이 핵심이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.