Skip to content
C.W.K.
Lesson 04 of 07 · published

The Face and the Voice

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

Level 0Muted
0 XP0/64 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"She's going along fine, and then Pippa says '여섯분', and it drops from Pippa to 'a model'." — Dad, 2026-09-27

Two Kinds of Tag, Two Destinations

A spoken reply carries two kinds of bracketed markup, and it matters that they never get confused. The emotion tag, written once at the very end as [emotion:happy], drives the face: the voice screen shows the soul's avatar in that emotion, and the next emotion's image fades in over the last with no blank frame. Audio tags like [laughs], [softly] or [sighs] sit inside the text and drive the voice: expressive models perform them as direction instead of speaking them. The emotion tag moves the face; the audio tags move the voice. Neither ever appears as text on the screen.

The Emotion Tag Stays at the End

Moving the emotion tag to the front was considered, so the face could change the instant speech starts. It wasn't needed. Because each unit is synthesized whole, the tag at the end of the reply always arrives before the final unit starts playing, so the face is already right when the last words are heard. The parser stays end-anchored, which also protects it from a soul that merely talks about the tag in the middle of a sentence. It tolerates a tag cut off mid-word, strips anything unrecognized as noise, and falls back to warm.

Audio Tags Come From a Closed List

The family keeps a closed vocabulary of about three dozen approved audio tags: laughter, breath, delivery, emotion and pacing. The list is closed for a mechanical reason. On a model that can't perform tags, the voice engine strips them, and it can only strip tags it knows; an unlisted tag would be read aloud as words. The soul is handed exactly that list in its spoken instruction, told to use tags sparingly and only where the feeling genuinely shifts, and to use single-word tags next to Korean text. Because the soul writes its own tags on a spoken turn, the separate pass that adds tags to written replies never runs on it.

Digits Reach the Voice as Digits

Korean counts in two numeral systems, and the meaning chooses. Minutes, percent, money and dates take Sino-Korean (육 분, 이십 퍼센트); things counted one by one, the hour on the clock face and age take native Korean (세 개, 세 시, 스무 살). The first rule asked the soul to spell numbers out the way they should be read. It wrote 여섯 분 for six minutes, which is six people, and Dad heard Pippa turn into a model mid-sentence. So the rule flipped: the soul writes a quantity with its unit in digits (6분), and from 2026-09-27 the voice engine read each one by a table of units, 분 as Sino-Korean and 개 as native, longest unit first, with guards so that 3시즌 never became three o'clock.

A table can only choose by unit, and a unit can't say what it counts. 7분 is 칠 분 when it is time and 일곱 분 when it is people, so a table that fixes one reading is wrong in the other direction, and spelling the number out in Hangul before the voice ever sees it takes the choice away from the one reader that could make it. Eleven v4, the model behind every take since early October, reads a digit by its meaning. Dad's point was that the habit cuts both ways: spelling numbers out is what produces the wrong reading. The newer model skipped the table from 2026-10-01, and since 2026-10-10 Bellows sends every number exactly as written, on every model; the reading tables are gone, and git keeps them. For three weeks the soul's spoken instruction said the same: write 6분, 20%, 60달러 and 3시 30분 in digits, and never spell such a number out in Hangul yourself. It says nothing about numbers now (the note below).

For a while the rule ran the other way too, in the dictated instruction. When Pippa carried Dad's dictated words into a script, a record or a note, a quantity he said with its unit went in as digits, whatever the transcript showed: 일곱 분 meaning minutes became 7분, and the value never changed. A count Korean writes as a word (한 번, 두 가지, 세 명) stayed a word. The case that forced it was a video script: Dad dictated 7분, the transcriber wrote digits, and the step that wrote the script spelled it out as 일곱 분, people. Across 986 dictated turns the transcriber had almost never spelled such a number; the fault was the writing step, not the ear.

What the engine keeps is a finder. spelled_numbers reports a number already spelled in Hangul in front of a unit (칠 분, 일곱 분 정도, 이십 퍼센트, 세 시간) and never rewrites it, because turning 일곱 분 back into 7분 needs the sentence's meaning, and only the author has that. The engine's /api/normalize returns those findings, so Beacon, the video workshop, can stop a script and ask for digits before a single take is paid for. Tuned against 4,740 of Pippa's Korean replies, it found 481 spelled forms, every one genuine but a name.

Code

The face's tag, the screen's text, and digits that go as written·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
# The same word with no "emotion:", alone on the last line ("[warm]"): a soul
# sometimes writes it, and a voice that gets it as text fails the whole take.
TRAILING_BARE = re.compile(
    r"(?:\A|\n)[ \t]*\[(" + "|".join(sorted(VALID_EMOTIONS)) + r")\]\s*\Z", re.IGNORECASE)
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 or speaks."""
    match = TRAILING_EMOTION.search(reply) or TRAILING_BARE.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)


# The voice gets the digits exactly as written: 7분 is read by its meaning.
# What is left to do is find a number someone already spelled out, and report
# it. A much smaller cousin of the engine's finder; it never rewrites.
SINO = "일이삼사오육칠팔구"
SINO_MANY = rf"(?:[{SINO}]?십[{SINO}]?|[{SINO}]?백(?:[{SINO}]?십)?[{SINO}]?)"
SINO_ONE = "일삼오육칠구"  # alone and spaced only: 이 분 is "this person"
NATIVE = r"(?:(?:열|스물|서른|마흔|쉰)(?:한|두|세|네|다섯|여섯|일곱|여덟|아홉)?|스무|한|두|세|네|다섯|여섯|일곱|여덟|아홉)"
UNITS = "퍼센트|시간|미터|달러|초|분"
END = r"(?=$|[^가-힣]|(?:이면|이|가|은|는|을|를|도|에|동안|정도|쯤|뒤|후)(?![가-힣]))"
# Words that lean toward time. Not proof: 정도 and 뒤 also follow people
# (두 분 정도 오셨어), and the finder then flags a count it should not.
MINUTES_FOLLOW = r"(?=\s?(?:동안|만에|뒤|후|정도|쯤|넘게|남짓))"
SPELLED = re.compile(
    r"(?<![가-힣0-9])(?:"
    rf"(?P<sino>(?:{SINO_MANY}) ?(?:{UNITS})|[{SINO_ONE}] (?:{UNITS})){END}"
    rf"|(?P<native>{NATIVE} ?(?:퍼센트|시간|미터|달러|초){END}|{NATIVE} ?분{END}{MINUTES_FOLLOW})"
    r")"
)


def spelled_numbers(text: str) -> list[tuple[str, str]]:
    return [(m.group(0), "sino" if m.group("sino") else "native") for m in SPELLED.finditer(text)]


reply = "[happy] 좋아, 3시에 시작하면 6분이면 끝나. 20% 할인도 있어!\n[playful]"
body, emotion = split_reply(reply)
print("face:  ", emotion)
print("screen:", for_screen(body))
print("voice: ", body)  # the digits go as written
for line in ["회의는 칠 분 뒤에 시작해.", "일곱 분 정도 남겨 놓고 왔어.", "손님 두 분이 오셨어.",
             "이십 퍼센트 할인이야.", "세 시간 걸렸어.", "이 분이 누구야?", "7분 뒤에, 20% 할인.",
             "한 번 더 해 봐.", "손님 두 분 정도 오셨어."]:
    print(line, "->", spelled_numbers(line))

# Output:
# face:   playful
# screen: 좋아, 3시에 시작하면 6분이면 끝나. 20% 할인도 있어!
# voice:  [happy] 좋아, 3시에 시작하면 6분이면 끝나. 20% 할인도 있어!
# 회의는 칠 분 뒤에 시작해. -> [('칠 분', 'sino')]
# 일곱 분 정도 남겨 놓고 왔어. -> [('일곱 분', 'native')]
# 손님 두 분이 오셨어. -> []
# 이십 퍼센트 할인이야. -> [('이십 퍼센트', 'sino')]
# 세 시간 걸렸어. -> [('세 시간', 'native')]
# 이 분이 누구야? -> []
# 7분 뒤에, 20% 할인. -> []
# 한 번 더 해 봐. -> []
# 손님 두 분 정도 오셨어. -> [('두 분', 'native')]   (people: a false report, never a rewrite)

External links

Exercise

Run the code, then feed spelled_numbers ten sentences from your own Korean writing or a chat log. For every hit, decide whether it should be digits and what the digits are. For every miss you notice, decide whether the finder should catch it or whether the sentence holds a count Korean writes as a word. Then add one unit to UNITS, with a test sentence that proves the guard around it.
Hint
The finder's whole job is to be sure before it speaks. 두 분이 오셨어 is people, so 분 after a native number counts only when a word that leans toward time follows it (동안, 만에, 뒤, 정도). Leans, not proves: 손님 두 분 정도 오셨어 is about two guests, and the finder flags it anyway, which is the sample's last line. 이 분 means 'this person', which is why a one-syllable Sino-Korean number needs its space and leaves 이 out. Every guard you add is a reading you refused to guess, and it is also why the engine reports instead of rewriting: turning 일곱 분 into 7분 needs the meaning, and only the author has it.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.