본문 바로가기
C.W.K.
Stream
Lesson 04 of 04 · published

얼굴과 목소리

~14 min · emotion-tag, audio-tags, korean-numerals, avatar

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"잘 나가다가 피파가 '여섯분' 이러면, 피파가 아니라 '모형'으로 수준이 급락하는 느낌" — 아빠, 2026-09-27

태그 두 종류, 가는 곳도 둘

말하는 답엔 대괄호 표시가 두 종류 들어 있고, 둘이 절대 섞이지 않는 게 중요해. 감정 태그는 맨 끝에 딱 한 번 [emotion:happy]처럼 적히고, 얼굴을 움직여. 음성 화면은 소울 아바타를 그 감정으로 보여주고, 다음 감정 이미지는 빈 프레임 없이 앞의 것 위로 스르르 떠올라. 오디오 태그는 [laughs], [softly], [sighs]처럼 글 안에 있고, 목소리를 움직여. 표현력 있는 모델은 이걸 소리 내어 읽지 않고 연기 지시로 받아들여. 감정 태그는 얼굴을, 오디오 태그는 목소리를 움직여. 어느 쪽도 화면에 글자로는 절대 안 나와.

감정 태그는 끝에 남아

말이 시작되는 순간 얼굴이 바뀌게 하려고 감정 태그를 앞으로 옮기는 것도 검토했어. 필요 없었어. 단위마다 통째로 합성하니까, 답 끝의 태그는 언제나 마지막 단위가 재생되기 전에 도착해. 그래서 마지막 말이 들릴 땐 얼굴이 이미 맞아 있어. 해석기는 끝에 고정된 채로 남고, 덕분에 문장 한가운데서 태그에 관해 그냥 이야기하는 소울에게 속지도 않아. 단어 중간에 끊긴 태그도 받아주고, 모르는 건 잡음으로 걷어내고, 따뜻함으로 돌아가.

오디오 태그는 닫힌 목록에서

가족은 승인된 오디오 태그를 서른 몇 개로 닫아 두고 있어. 웃음, 숨, 전달 방식, 감정, 빠르기. 닫아 둔 이유는 기계적이야. 태그를 연기 못 하는 모델에선 음성 엔진이 태그를 걷어내는데, 엔진은 아는 태그만 걷어낼 수 있어. 목록에 없는 태그는 글자 그대로 소리 내어 읽혀. 소울은 말하기 지시에서 바로 그 목록을 받고, 아껴서, 감정이 진짜로 바뀌는 곳에만 쓰고, 한국어 옆에선 한 단어짜리 태그만 쓰라는 말을 들어. 말 턴에선 소울이 태그를 직접 쓰니까, 글 답에 태그를 덧붙이는 별도 단계는 여기선 안 돌아.

숫자와 그 숫자가 달고 다니는 단위

한국어는 수를 두 체계로 세고, 어느 쪽인지는 단위가 골라. 분, 퍼센트, 돈, 날짜는 한자어(육 분, 이십 퍼센트)고, 하나씩 세는 것, 시계판의 시, 나이는 고유어(세 개, 세 시, 스무 살)야. 다만 열두 시를 넘는 시는 다시 한자어야(십삼 시). 처음 규칙은 소울에게 읽을 대로 숫자를 풀어 쓰라고 했어. 소울은 6분을 여섯 분이라고 썼고, 그건 여섯 사람이라는 뜻이야. 아빠는 문장 한가운데서 피파가 모형이 되는 걸 들었어. 이제 규칙은 정반대야. 소울은 단위가 붙은 수량을 숫자로 쓰고(6분), 음성 엔진이 단위에 맞는 수사로 읽어. 읽기를 엔진이 쥐니까, 말하는 가족 앱은 전부 이걸 얻어. 바로 다음 날 사례가 하나 더 왔어. '에어컨 9대'를 '구 대'로 읽은 거야. 기계는 고유어로 세고(아홉 대), 딱 떨어지는 십 단위 숫자에 대가 붙을 때만(이십 대) 나이대라서 한자어야. 규칙엔 읽는 법이 확실한 단위만 적고, 애매한 단위는 목소리에 맡겨. 단위가 어디서 끝나는지도 알아야 해. 개만 떼면 고유어지만 12개월은 한자어고(십이 개월), 3시즌은 세 시가 아니야. 그래서 겹친 단위는 긴 것부터 맞추고, 짧은 단위엔 더 긴 단어의 머리가 되게 만드는 글자를 막는 가드를 붙여.

Code

얼굴의 태그, 화면의 글, 목소리의 수사·python
import re

VALID_EMOTIONS = {"warm", "happy", "excited", "playful", "serious", "concerned", "surprised", "sad"}
TRAILING_EMOTION = re.compile(r"\[emotion:(\w*)\]?\s*$", re.IGNORECASE)  # end-anchored
AUDIO_TAG = re.compile(r"\[(?:[a-z]+(?: [a-z]+){0,2})\](?!\()[ \t]?")  # not [x](url)


def split_reply(reply: str) -> tuple[str, str]:
    """(body, emotion). The face reads the emotion; the tag never shows."""
    match = TRAILING_EMOTION.search(reply)
    if not match:
        return reply.strip(), "warm"
    emotion = match.group(1).lower()
    return reply[: match.start()].rstrip(), emotion if emotion in VALID_EMOTIONS else "warm"


def for_screen(body: str) -> str:
    """Audio tags are performed by the voice, never shown as text."""
    return AUDIO_TAG.sub("", body)


SINO = "_일이삼사오육칠팔구"
NATIVE_ONES = ["", "한", "두", "세", "네", "다섯", "여섯", "일곱", "여덟", "아홉"]
NATIVE_TENS = ["", "열", "스무", "서른", "마흔", "쉰", "예순", "일흔", "여든", "아흔"]
UNIT_SYSTEM = {"분": "sino", "초": "sino", "달러": "sino", "퍼센트": "sino",
               "개월": "sino", "개국": "sino", "시간": "native",
               "개": "native", "명": "native", "시": "hour", "살": "native"}
# A unit must not be the head of a longer word: 3시즌 is not three o'clock.
GUARD = {"개": "(?![국년월소발념])", "명": "(?![령단함소])", "살": "(?![짝림균])",
         "시": "(?![즌리점기각절대청장민내외험작공])"}


def sino(n: int) -> str:
    tens, ones = divmod(n, 10)
    head = "" if tens == 0 else ("십" if tens == 1 else SINO[tens] + "십")
    return head + ("" if ones == 0 else SINO[ones])


def native(n: int) -> str:
    tens, ones = divmod(n, 10)
    ten = NATIVE_TENS[tens]
    if tens == 2 and ones:
        ten = "스물"                       # 스무 only stands alone before a counter
    return ten + NATIVE_ONES[ones]


def read_numerals(text: str) -> str:
    """Digits with a unit, read in the numeral the unit takes (1-99 here)."""
    def spell(match: re.Match) -> str:
        n, unit = int(match.group(1)), match.group(2)
        if not 0 < n < 100:
            return match.group(0)
        system = UNIT_SYSTEM[unit]
        if system == "hour":                    # the clock face is native, 1-12 only
            system = "native" if n <= 12 else "sino"
        word = sino(n) if system == "sino" else native(n)
        return f"{word} {unit}"
    units = "|".join(re.escape(u) + GUARD.get(u, "")        # longest first: 개월 before 개
                     for u in sorted(UNIT_SYSTEM, key=len, reverse=True))
    return re.sub(rf"(\d+)\s?({units})", spell, text)


reply = "[happy] 좋아, 3시에 시작하면 6분이면 끝나. 20퍼센트 할인도 있어!\n[emotion:happy]"
body, emotion = split_reply(reply)
print("face:  ", emotion)
print("screen:", for_screen(body))
print("voice: ", read_numerals(body))
print("voice: ", read_numerals("12개월 동안 3개국, 3시즌. 13시 회의는 2시간, 발표는 13시간 뒤."))
print(split_reply("알았어. [emotion:exc"))              # truncated tag -> stripped, warm

External links

Exercise

코드를 돌려보고, UNIT_SYSTEM에 단위 셋을 더 넣어봐. 한자어 하나(예: 미터), 고유어 하나(예: 마리), 그리고 네가 보기에 애매한 것 하나. 애매한 걸 읽기 규칙이 어떻게 해야 할지 정하고, 목소리에 맡길지 말지 이유를 대.
Hint
번이 대표적인 애매한 단위야. 세 번은 세 차례지만, 일 번은 첫 번째 번호야. 무슨 규칙을 써도 반은 틀려. 엔진의 규칙은 읽는 법이 확실한 단위만 적고 나머진 목소리에 맡기는 거고, 글쓴이가 6분을 숫자로 쓰는 이유도 같아. 분은 확실하지만, 풀어 쓴 여섯 분은 아니거든.

Progress

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

댓글 0

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

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