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

말 단위는 통째로, 절대 안 쪼개

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

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Do not split a speakable unit to chase first-play latency." — Bellows 불변식 19, 아빠의 2026-08-20 결정

목소리를 망치는 지연 줄이기 요령

음성 파이프라인은 결국 다 같은 유혹을 만나. 답 전체를 기다리지 말고, 첫 문장이 생기자마자 합성해서 나머지가 쓰이는 동안 재생부터 하자는 거. 첫 소리까지의 시간은 확실히 줄어. 대신 목소리가 귀에 들릴 만큼 나빠져. eleven_v3 같은 표현력 있는 모델은 사람처럼 한 대목을 읽어. 말투와 빠르기와 억양을 문장 너머까지 끌고 가지. 그 대목을 요청 여러 개로 자르면 조각마다 식은 채로 시작해. 경계마다 억양이 초기화되고 흐름이 엉성해져. 게다가 eleven_v3는 옛 모델들이 이음매를 매끄럽게 하려고 쓰던 맥락 이어 붙이기 힌트도 안 받아. 아빠는 그 차이를 듣고 8월 20일에, 음성 모드보다 다섯 주 먼저 결정했어. 말 단위 하나에 합성 하나. 일찍 시작하려고 절대 쪼개지 마.

단위란 뭔가

말 단위는 말하는 답에서 이어진 한 구간이야. 경계는 딱 둘뿐이야. 도구 호출 앞의 글이 한 단위야. 도구 호출은 자연스러운 쉼이고 그 앞 글은 확정이니까. 마지막 도구 호출 뒤의 글이 또 한 단위고. 도구 없는 짧은 말 답은 단위 하나야. 단위 안에선 카드와 끝의 감정 태그를 걷어내고, 나머지는 오디오 태그와 줄바꿈까지 쓰인 그대로 엔진에 가.

서버는 흘려보내면서 단위가 끝나는 위치를 적어 두고, 클라이언트는 커서에서부터 단위를 순서대로 가져가. 커서 뒤의 기록된 끝마다 단위 하나가 나오고, 답이 확정되면 나머지 전부가 마지막 단위가 돼. 단위마다 답 안의 글자 범위도 같이 들고 다니는데, 트랙 7에서 아빠가 어디까지 들었는지 계산할 때 이게 필요해.

왜 대가가 그렇게 작나

단위 전체를 기다린다니 비싸 보이지만, 소울이 얼마나 빨리 쓰는지 재 보면 달라. 글이 흐르기 시작하면 답은 대략 초당 100~150자로 도착해. 서너 문장짜리 말 답은 첫 단어 뒤 2초쯤이면 끝나. 첫 단어가 생기기도 전에 트랙 8이 잰 바닥 시간에 비하면 작은 값이고, 그걸로 첫 숨부터 마지막 숨까지 말투가 흔들리지 않는 한 테이크를 사.

초기화, 그리고 옛 문단 재생기

WebUI엔 원래 글 답을 문단 하나씩 소리 내어 읽어주는 문단 자동 음성이 있었는데, 이것도 같은 불변식을 거슬러. 말 턴은 이걸 안 써. 음성 모드를 만들다가 이 재생기의 버그도 찾았어. 스트림이 초기화되면(답을 처음부터 다시 만들면) 버려진 판본의 문단들이 줄에 선 채로 계속 재생됐어. 말 단위의 규칙은 엄격해. 스트림이 초기화되면 소리를 멈추고 줄 선 단위를 전부 버려.

Code

흘러오는 답에서 단위를 통째로 가져가기·python
import re

CARD = re.compile(r"^(`{3,})card[ \t]*\n.*?\n\1[ \t]*$\n?", re.MULTILINE | re.DOTALL)
EMOTION_TAG = re.compile(r"\[emotion:[a-z]+\]?\s*$", re.IGNORECASE)


def speakable(unit: str) -> str:
    return EMOTION_TAG.sub("", CARD.sub("", unit)).strip()


def take_units(content: str, unit_ends: list[int], cursor: int, final: bool):
    """Whole units since `cursor`, with their ranges in the reply."""
    units, ranges, start = [], [], cursor
    for end in unit_ends:
        if end <= start or end > len(content):
            continue
        if text := speakable(content[start:end]):
            units.append(text)
            ranges.append((start, end))
        start = end
    if final and start < len(content):
        if text := speakable(content[start:]):
            units.append(text)
            ranges.append((start, len(content)))
        start = len(content)
    return units, ranges, start


before_tool = "잠깐, 일정 확인해볼게. "
after_tool = ("오늘은 3시에 치과 하나뿐이야. [softly] 그 전엔 푹 쉬어도 돼.\n\n"
              "```card\n| time | what |\n|---|---|\n| 15:00 | dentist |\n```\n"
              "[emotion:warm]")

# Mid-stream: only the part before the tool is final.
streaming = before_tool + after_tool[:20]
units, ranges, cursor = take_units(streaming, [len(before_tool)], 0, final=False)
print(units, ranges)

# Done: the rest becomes one whole unit, card and emotion tag removed.
reply = before_tool + after_tool
units, ranges, cursor = take_units(reply, [len(before_tool)], cursor, final=True)
print(units, ranges)

External links

Exercise

코드를 돌려보고, 답을 도구 호출 두 번에 각각 앞에 짧은 한 줄이 붙은 모양으로 늘려봐. 단위가 셋 나오고, 범위가 끊김 없이 이어져서 답 전체를 덮는지 확인해. 마지막으로 초기화를 넣어. 답 도중에 스트림이 다시 시작되는 걸 흉내 내고, 재생을 멈추고 줄을 비우는 함수를 써. 버려진 스트림의 단위가 절대 재생되지 않는지 확인하는 테스트도.
Hint
스트림에 세대 번호를 둬. 줄 선 단위는 전부 자기가 나온 세대를 들고 있고, 재생기는 현재보다 오래된 세대의 단위를 버려. 그걸로 줄 선 단위는 해결되지만, 이미 재생 중인 단위는 아니야. 재생기가 그건 직접 멈추고 소리를 놓아줘야 해. 그래서 초기화는 두 동작이야. 재생 중인 걸 멈추고, 번호를 올리기. 그러면 줄 선 낡은 단위는 알아서 빠져.

Progress

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

댓글 0

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

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