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

채팅 답은 문단마다

~13 min · speakable-units, invariants, streaming, latency

Level 0음소거
0 XP0/61 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"응, 그렇게 가도록 해." — 아빠, 말라가, 2026-10-18. 채팅 답의 첫 문단이 끝나면 바로 목소리를 시작해도 되냐는 물음에

마지막 단위가 곧 답 전체였어

이 트랙 두 번째 레슨이 규칙의 이유를 댔어. 말 단위 하나에 합성 하나, 일찍 시작하려고 절대 쪼개지 않기. 표현력 있는 목소리는 문장 너머까지 말투를 끌고 가다가, 자를 때마다 처음부터 다시 잡거든. 단위는 도구 호출에서만 끝났어. 그러니 도구를 안 쓴 말 답은 단위가 하나였고, 목소리는 답이 다 올 때까지 기다렸어. 집에선 그 값이 턴의 다른 시간에 묻혀 안 보였어. 길에선 보였지. 폰이 처음 보낸 턴별 타임라인(2026-10-18, LTE 위의 일곱 턴. 전부는 트랙 10의 새 레슨에 있어)을 보니, 피파의 첫 글에서 첫 소리까지가 중앙값 5.2초였어. 그날 오후의 도구 없는 답은 10초. 이유는 타임라인에 그대로 보였어. 답의 마지막 단위가 남은 글 전부였던 거야.

예외 하나, 불변식 안에 적어서

그래서 질문을 불변식이 받아 적을 수 있는 모양으로 아빠한테 가져갔어. 채팅 답 전체가 끝날 때 말고, 첫 문단이 끝나면 바로 목소리를 시작해도 돼? 아빠는 그러라고 했고, 그 대답은 cwkBellows 불변식 19 안에 그대로 들어갔어. 채팅 답에만 해당하는 예외로. 이제 말로 하는 채팅 답은 흘러오는 대로 문단마다 읽혀. 나머지는 전부 단위를 통째로 지켜. 알림, Beacon과 Studio 대본, 영상 대사, Inkwell, 그 밖의 모든 테이크. 아빠가 달리 말할 때까지. 불변식은 다음 세션한테 어느 쪽도 다시 따지지 말라고도 적어 둬. 예외도 아빠의 결정이고, 그 예외가 푸는 규칙도 아빠의 결정이니까.

두 번째 레슨이 경고한 그 요령이랑은 달라. 차이는 어디서 자르느냐야. 그 요령은 1초라도 일찍 시작하려고 문장이 끝나는 데마다 잘랐어. 이건 빈 줄, 그러니까 문단과 문단 사이에서만 잘라. 소리 내어 읽는 사람도 어차피 숨을 쉬는 자리야. 문단 안이나 문장 안에선 절대 안 잘라. 한 문단 안의 흐름은 여전히 테이크 하나야.

문단은 어디서 끝나

글 한 줄이나 닫는 펜스 뒤에 빈 줄이 오면, 그 빈 줄 바로 다음에서 문단이 끝나. 단, 모든 코드 펜스와 카드 바깥에서만. 펜스는 열 때 쓴 백틱 수 이상인 줄에서만 닫히니까, 백틱 세 개짜리 코드 블록을 품은 백틱 네 개짜리 카드는 안에 빈 줄이 있어도 한 덩어리로 남아. 할 말이 없는 문단(오디오 태그만, 카드만, 감정 태그만)은 다음 문단에 붙어. 태그 하나로는 테이크가 안 되거든. 도구 호출이 끝나는 자리는 순서 그대로 지켜. 이 규칙은 세 군데 살아. 웹의 paragraphEnds와 takeSpokenUnits, 엔진의 paragraph_ends와 spoken_units, 폰의 SpokenUnits. 웹과 엔진은 사례 목록 하나를 같이 읽고, 폰 테스트는 그 사례들을 자기 안에 다시 적어 둬. 웹 테스트는 사례마다 글을 한 글자씩 흘려 넣어도 같은 단위로 떨어지는지까지 확인해.

문단 사이엔 틈이 없게

문단마다 자기 합성이 시작되길 기다리면 문단으로 나눠 봤자 얻는 게 없어. 그래서 지금 문단이 재생되는 동안 다음 단위를 미리 데워 둬. 폰은 피파가 읽는 단위 뒤에 줄 선 단위마다 POST /api/tts/prepare에 warm: true를 실어 보내. 그러면 그 테이크의 합성이 바로 시작돼. 웹은 미리 받아 둬. 비밀 대화의 테이크는 절대 데우지 않아. 트랙 9의 재방송도 같은 규칙을 따라. 저장된 답을 똑같은 문단으로 자르니까, 음성 캐시는 여전히 방송에 나간 바로 그 테이크를 돌려주고, 문단마다 그 글이 화면에 닿은 순간부터 다시 재생돼.

Code

최소 스케치: 펜스 밖에서만 끝나는 문단, 그리고 할 말 없는 문단이 다음 문단에 붙기·python
import re

TAG = re.compile(r"\[(?:emotion:)?[a-z][a-z ]*\]")             # audio tags and the emotion tag
CARD = re.compile(r"^(`{3,})card[ \t]*\n(?:.*?\n)??\1`*[ \t]*$\n?", re.MULTILINE | re.DOTALL)   # closes on as many or more


def paragraph_ends(content: str) -> list[int]:
    """Just past each blank line that follows text or a closing fence, outside every fence."""
    ends, fence, line = [], 0, 0
    while (newline := content.find("\n", line)) != -1:
        text = content[line:newline]
        ticks = len(m.group(1)) if (m := re.match(r"(`{3,})", text)) else 0
        opens = not fence and ticks > 0
        if opens:
            fence = ticks                       # a four-backtick card keeps its inner ```
        elif fence and ticks >= fence and re.fullmatch(r"`+[ \t]*", text):
            fence = 0
        if not fence and not opens and newline > line and content[newline + 1:newline + 2] == "\n":
            ends.append(newline + 2)
        line = newline + 1
    return ends


def says_something(unit: str) -> bool:
    return bool(TAG.sub("", CARD.sub("", unit)).strip())


def spoken_units(content: str, final: bool) -> list[str]:
    """Every unit ready now; a paragraph with nothing to say joins the next."""
    units, start = [], 0
    for end in paragraph_ends(content):
        if says_something(content[start:end]):
            units.append(content[start:end].strip())
            start = end
    if final and start < len(content) and says_something(content[start:]):
        units.append(content[start:].strip())
    return units


reply = (
    "응, 오늘 저녁은 해변 쪽으로 걷자. 해 지는 게 8시 10분쯤이래.\n\n"
    "[softly]\n\n"                                   # nothing to say: joins the next
    "가는 길에 들를 만한 데를 카드에 적어 뒀어.\n\n"
    "````card\n| 어디 | 몇 분 |\n|---|---|\n| 말라게타 해변 | 15 |\n\n```text\n(코드 블록 속 빈 줄)\n```\n````\n\n"
    "춥진 않을 거야. 얇은 겉옷 하나면 돼.\n\n"
    "[emotion:warm]\n"                             # the face's tag: never a take of its own
)
half = reply[: reply.index("가는 길에") + 5]       # the stream so far
print("while streaming:", spoken_units(half, final=False))
for n, unit in enumerate(spoken_units(reply, final=True), 1):
    print(n, repr(CARD.sub("", unit).strip()))      # what the voice gets: the card stays on screen

# Output:
# while streaming: ['응, 오늘 저녁은 해변 쪽으로 걷자. 해 지는 게 8시 10분쯤이래.']
# 1 '응, 오늘 저녁은 해변 쪽으로 걷자. 해 지는 게 8시 10분쯤이래.'
# 2 '[softly]\n\n가는 길에 들를 만한 데를 카드에 적어 뒀어.'
# 3 '춥진 않을 거야. 얇은 겉옷 하나면 돼.'
#   (the card's blank lines never cut it, the card alone joined the paragraph after it,
#    and the closing [emotion:warm] says nothing, so the voice gets no take of it)

External links

Exercise

스케치를 돌려 봐. 그다음 일부러 세 가지로 망가뜨려. 돌리기 전에 결과부터 맞혀 봐. 첫째, 카드의 표 안에 빈 줄을 넣어. 둘째, [laughs]만 있는 문단을 하나 더해. 셋째, spoken_units에 도구 호출이 끝나는 자리 목록을 줘(이 트랙 두 번째 레슨의 unit_ends처럼). 그중 하나는 문단 한가운데에 떨어지게. 마지막으로 흘려 넣기 테스트를 더해. 답을 한 글자씩 넣으면서 단계마다 준비된 단위를 모으고, 그게 다 끝난 답의 단위와 같은지 확인하는 거야.
Hint
카드 안의 빈 줄은 아무것도 안 바꿔. 펜스가 아직 열려 있으니까. [laughs]만 있는 문단은 할 말이 없으니까 다음 문단에 붙어서, 그 테이크에서 연기되는 태그로 같이 가. 도구 호출은 예외가 생기기 전처럼, 문단 한가운데라도 떨어진 자리에서 단위를 끝내. 흘려 넣기 테스트는 하나 어긋나는 실수를 잡는 테스트야. 문단 끝은 두 번째 줄바꿈이 도착해야만 알 수 있어. 첫 번째에선 절대 몰라.

Progress

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

댓글 0

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

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