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

아빠는 어디까지 들었을까?

~15 min · heard-until, streaming-audio, estimation, records

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"주장은 사실보다 모자랄 순 있어도, 절대 넘어서진 않아." — 음성 모드 설계 문서. 이 레슨이 바로잡을 문장이야

위치가 왜 중요해

아빠가 끼어들면 답은 멈추지만, 답의 글은 기록에 온전히 남아. 다른 걸 안 적어 두면 소울의 다음 턴은 아빠가 전부 들었다고 여길 거야. 아니야. 끊긴 뒤의 부분은 아빠 귀에 닿은 적이 없고, 그걸 다시 꺼내는 소울("아까 우산 얘기했잖아")은 아빠가 못 들은 얘기를 하는 거야. 그래서 끼어들 때마다 위치를 알려. 답의 몇 글자까지 아빠가 실제로 들었는지.

소리 시간을 글자로

말 단위마다 답 안의 글자 범위를 들고 있어. 트랙 4가 범위를 챙기라고 고집한 이유가 이거야. 끊는 순간 클라이언트는 어느 단위가 재생 중이었고 소리가 그 안에서 얼마나 갔는지 알아서, 소리의 그 몫을 단위 글자의 그 몫으로 옮겨. 엔진은 그 주장으로 두 가지를 해. 먼저 그 지점이나 그 앞의 마지막 문장 끝으로 되돌려. 반만 들은 문장은 안 들은 걸로 치거든. 문장 끝은 마침표, 물음표, 느낌표, 말줄임표 뒤에 공백이 오거나, 줄바꿈이야. 한국어 말은 줄 끝에서 마침표 없이 문장을 끝내는 일이 많으니까. 찾는 방법이 중요해. 답 전체에서 찾고, 끊긴 지점이나 그 앞의 마지막 끝을 골라야 해. 끊긴 지점까지만 찾으면 거기서 '글 끝'이 맞아버려서, '3.5초'의 '3.'이 문장 끝이 돼. 이 퀘스트를 쓰는 지금, 실제 엔진은 끊긴 지점까지만 찾고, 딱 그 틈이 있어. 그다음 결과를 두 군데 적어. JSONL 기록에 한 줄, 메시지 행에 한 번.

길이를 모르는 테이크

첫 버전엔 실제 스트리밍만 드러낼 수 있는 버그가 있었어. 단위의 몫을 구하려고 소리의 현재 시간을 전체 길이로 나눴어. 그런데 흘러나오는 테이크는 재생되는 동안 길이를 몰라. 크롬은 재생 위치보다 1~2초 앞까지만 길이를 알고, 마지막 바이트가 도착하는 끝 무렵까진 무한대라고 보고해. 읽기가 속도를 맞춰 이뤄지니까 길이도 네트워크 상태도 내려받기가 끝난 순간을 알려주지 않아. 코드는 길이를 모를 때 '단위의 시작'이라고 답하게 돼 있었어. 그래서 녹화한 시연에서 55초짜리 테이크의 53초에 끊은 게 0으로 기록됐고, 다음 답은 시청자들에게 아빠가 첫 문장이 끝나기도 전에 끼어들었다고 말했어.

0 대신 조심스러운 짐작

고친 건 폰의 규칙이고, 이제 두 클라이언트가 같이 써. 길이를 모르면 테이크가 적어도 이미 도착한 만큼은 길고, 적어도 일부러 느리게 잡은 초당 6자로 그 글을 읽는 만큼은 길다고 쳐. 테이크를 길게 짐작하면 소리 1초가 더 적은 글자로 계산돼서, 주장이 사실보다 짧은 쪽으로 기울어. 151자짜리 영어 테이크(끝나면 9.5초) 4초 지점에서 이 규칙은 23자를 주장해. 정확한 몫은 62자였고, 옛 규칙은 0이라고 했어. 모자란 건 안전해. 다음 턴이 조금 되풀이할 뿐이야. 넘치면 소울은 아빠가 못 들은 걸 들었다고 여겨.

설계 문서는 이걸 보장처럼 적었어. 모자랄 순 있어도 넘진 않는다고. 보장이 아니라 기울기야. 끊긴 지점까지의 실제 속도가 초당 6자보다 빠를 때만 맞아. 느리고 감정이 실린 테이크는 짐작보다 길어지고, 그만큼 버퍼가 차면 버퍼 길이가 이겨서 주장이 목소리를 앞질러. 평균 속도는 괜찮은 테이크라도 단위 초반에 긴 쉼이 있으면 똑같이 돼. 상수를 평소 말 속도보다 느리게 잡아서 넘치는 일이 드물 뿐, 없진 않아. 더 약한 문장을 적어. 그게 참인 문장이니까.

Code

소리 위치를 글자로: 길이를 모를 땐 조심스럽게, 그리고 문장 끝으로 되돌리기·python
import math
import re

HEARD_CHARS_PER_SECOND = 6    # cautious: guessing the take long makes the claim small


def heard_chars(unit_range: tuple[int, int], current_time: float, duration: float,
                buffered_seconds: float = 0.0) -> int:
    """Map a playback position inside one unit back to characters of the reply."""
    start, end = unit_range
    length = end - start
    if not current_time > 0 or length <= 0:
        return start
    if duration > 0 and math.isfinite(duration):
        total = duration
    else:                                      # a streamed take: length not known yet
        total = max(buffered_seconds, length / HEARD_CHARS_PER_SECOND)
    return start + math.floor(length * min(1.0, current_time / total))


SENTENCE_END = re.compile(r"(?:[.!?。!?…]+[\"'”’)\]]*)(?=\s|$)|\n")


def snap_heard(content: str, heard_until: int) -> int:
    """Back to the last sentence end at or before the cut: a half-heard sentence counts as unheard."""
    limit = max(0, min(heard_until, len(content)))
    if limit >= len(content.rstrip()):
        return len(content)
    snapped = 0
    for match in SENTENCE_END.finditer(content):   # the whole reply, so '$' means its real end
        if match.end() > limit:
            break
        snapped = match.end()
    return snapped


english = "x" * 151                           # a 151-character unit, 9.5 s when finished
old_rule = 0                                   # 'duration unknown -> the unit's start'
print("old rule:", old_rule, "| cautious rule:",
      heard_chars((0, 151), current_time=4.0, duration=float("inf")))

reply = "내일은 비가 와. 우산 챙겨. 오후엔 그친대\n저녁엔 쌀쌀하니까 겉옷도 가져가."
cut = reply.index("그친대") + 2              # he cut in mid-sentence
print(repr(reply[: snap_heard(reply, cut)]))

decimal = "It took 3.5 seconds. Then it stopped."
cut = decimal.index("3.") + 2                  # cut right after "3."
print(snap_heard(decimal, cut), "| searching only up to the cut would say",
      max((m.end() for m in SENTENCE_END.finditer(decimal, 0, cut)), default=0))

External links

Exercise

코드를 돌려봐. 그다음 테이크 길이를 모르고 25초가 버퍼에 들어온 600자짜리 단위의 30초 지점에서 끊었을 때의 주장을, 손으로 한 번, 함수로 한 번 계산해. 실제 테이크가 70초라면 주장이 사실보다 모자라? 40초뿐이라면? 마지막으로 snap_heard를 늘려서, 한국어 문장 한가운데의 말줄임표(예: '그게…' 뒤에 말이 더 이어지는 경우)는 문장 끝으로 치지 않게 하고, 그 규칙을 변호해.
Hint
600자면 조심스러운 길이는 100초고, 버퍼의 25초보다 길어. 그래서 30초는 30퍼센트, 180자를 주장해. 실제 70초짜리면 진짜 들은 건 약 257자라서 주장이 안전하게 모자라고, 40초짜리면 더 넉넉하게 덮여. 고른 테이크라면 주장이 넘치는 건 목소리가 초당 6자보다 느리게 말할 때뿐이고, 단위 안에서 속도가 고르지 않으면 평균이 더 빨라도 넘칠 수 있어. 그래서 상수를 일부러 느리게 잡았고, 그래도 여전히 보장이 아니라 기울기야. 말줄임표는 뒤에 줄바꿈이나 글 끝이 오도록 요구하면 더 엄격해지고, 더 엄격한 되돌리기는 주장을 더 짧게만 만드니까 안전한 쪽이야.

Progress

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

댓글 0

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

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