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

다시 통째로: 빠른 시작보다 끊김 없는 게 먼저

~15 min · speakable-units, streaming-audio, measurement, invariants

Level 0음소거
0 XP0/64 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"1, 2초 늦게 시작하더라도 안 끊기는 게 더 중요해." — 아빠, 말라가, 2026-10-19 밤(한국 시간 2026-10-20), 빌드 157에서

한 문장만 말한 답

앞 레슨은 지금 문단이 재생되는 동안 다음 테이크를 데워서 문단 사이 틈을 닫았어. 2026-10-19엔 그 생각을 더 밀어붙인 버전을 재 봤고, 숫자도 맞았어. 단위가 줄에 서는 순간 테이크를 열었더니, 빌드 155에서 단위 사이 경계 20개가 하나같이 0.09~0.17초만 조용했거든. 그런데 같은 날 저녁, 말라가 호텔 와이파이에 AirPods를 낀 빌드 156에서 일이 났어. 답은 서버에도 화면에도 온전히 있었어(done 40.4초, 에러도 없었어). 그런데 아빠 귀엔 "원칙은 하나야." 한 마디만 들렸고, 그 뒤론 아무것도 없었어. 폰엔 "Reconnecting — The engine did not answer in time"이 떠 있었어.

원인은 그때 피파 머신이던 맥의 접근 로그에 있었어. 답의 도구가 끝나는 순간, 폰이 테이크 준비 넷, 테이크 스트림 넷, 그리고 아빠 녹음의 마이크 레이트 사본 1.96 MB를 같은 1초 안에 쏟아냈어. 막 재생을 시작한 단위는 8.7초를 굶었고, 나머지 호출은 같은 링크 위에서 전부 시간 초과가 났어. 두 덩어리 다 우리가 만든 거야. 테이크를 한꺼번에 여는 건 틈을 없앤 바로 그 수정이었어. 턴 '뒤로' 미룬다던 사본은 턴의 done에 올라갔는데, 문단 읽기에선 그 시점이 피파 답 한복판이었어. 14분 전 Bellows 기록에도 같은 모양이 찍혀 있었어. 답 하나의 테이크 다섯 개가 0.75초 안에 만들어졌고, 재생 중이던 단위들은 다른 테이크가 만들어지는 동안 세 번 말라서 합쳐 8.51초를 멈췄어. 틈 측정이 잰 건 단위 사이의 침묵이었어. 단위 안의 굶주림은 전혀 안 쟀어.

목소리를 붙잡아 둔 버퍼

그날 낮, 붐비는 피카소 미술관에서 LTE로 쓰던 아빠가 이미 나머지 반쪽을 짚었어. "이제 이어지긴 하는데 특히 첫 응답이 뚝뚝 끊기는 현상이 잦아." 단위 사이 틈은 사라졌고, 이건 단위 안의 끊김이었어. 대개 첫 단위였지. 바이트가 도착하는 동안 재생되는 건 첫 단위뿐이거든. 원인은 키트의 StreamingSpeechPlayer에 있었어. 핸즈프리 대화 밖에서 읽는 건 전부 이 재생기 몫이야. 해석된 패킷은 32 KB 버퍼에서 기다렸다가 오디오 큐로 넘어갔어. 128 kbit/s로 2초쯤 되는 양이야. 그러니 재생보다 살짝 빠르게만 오는 스트림이면, 소리는 버퍼에 쌓여 있는데 스피커는 말라 버렸어. 마른 큐는 조각이 오는 족족 틀었어. 그래서 들린 건 한 단어, 침묵, 반 단어였어. 큐는 링크가 어떻든 소리 0.35초가 모이면 시작했어.

cwkSiblingKit a89401e(2026-10-19)가 그 버퍼를 걷어냈어. 이제 패킷은 본문 조각이 해석되는 대로 바로 큐에 가. 첫 소리는 손에 쥔 소리가 쿠션만큼 찰 때까지 기다려. 본문이 아직 오는데 큐가 마르려 하면 한 번 멈춰서 다시 채우고, 그다음 이어 가. 멈춤은 한 번, 더듬거림은 없어. 단위를 쪼개거나 처음부터 다시 시작하는 일은 절대 없어. 키트 벤치의 붐비는 셀(실시간의 1.6배, 1초마다 0.3~0.9초씩 막혀)에선, 멈춤 두 번에 1.48초 조용하던 게 0이 됐어.

두 번의 결정, 둘 다 귀로

그날 저녁 20:14에 아빠가 첫 결정을 내렸어. "처음에 그냥 통째로 읽기를 했을 때가 훨씬 기능이 나았던 것 같은데." 빌드 157(cbe99bd0)이 폰의 기본을 다시 통째 읽기로 돌렸어. 도구 앞의 한 줄은 여전히 오는 대로 읽고, 끝난 답의 나머지는 테이크 하나로 끝까지 읽어. 미리 받기에도 상한이 생겼어. 테이크를 열어 두는 건 재생 중인 단위와 바로 다음 단위뿐이야(PippaSpeechPlayback.prefetchDepth = 1). 그래도 틈 없이 이어지고, 재생 중인 단위와 다투는 건 많아야 하나야. 그날 밤 늦게 맥북 WebUI에서, 아빠는 폰이 이제 안 하는 짓을 웹이 하는 걸 들었어. "끊기는 데다 아예 건너 뛰어." 빌드 157의 스위치는 폰에만 닿았던 거야.

두 번째 결정은 빌드 157 위에서 나왔어. 호텔 와이파이로 들은 통째 답 하나가 첫 몇 초 안에 세 번 말랐어. 좋은 링크의 쿠션이 아직 0.35초였거든. 그걸 듣고 아빠가 한 말이 이 페이지 맨 위의 말이야. 빠른 시작과 안 끊기는 읽기 중에서 아빠는 안 끊기는 쪽을 골랐어.

그래서 문단 레슨의 예외는 도로 빠졌어. 말 단위 하나에 합성 하나라는 불변식 19는 한 번도 규칙에서 내려온 적이 없어. 아빠가 2026-10-18에 허락한 예외 하나가 하루를 갔고, 허락한 그 귀가 거둬들였어. 문단 자르기 코드는 안 지웠어. 스위치 뒤에서 기다려. 하루치 턴을 재서, 첫 소리와 끊김 0과 가짜 "Reconnecting" 0 모두에서 통째 읽기를 이긴다는 게 보여야만 돌아와.

스위치 하나, 자르는 쪽은 전부 거기에 대 봐

한 화면에만 있는 스위치는 없느니만 못했어. Bellows는 테이크를 단위의 글로 알아봐. 그래서 화면의 takeSpokenUnits, 서버의 spoken_units(GET /api/onair/record와 On Air 재방송이 이미 있는 테이크를 찾을 때 써), 폰의 SpokenUnits는 전부 글자 하나까지 똑같이 잘라야 해. 폰은 통째로 읽는데 서버는 저장된 답을 여전히 문단으로 자르던 동안, 기록 확인은 만든 적도 없는 테이크를 부를 수 있었어. 그래서 스위치는 한 곳에만 살아. 공유 사례 파일의 reads_by_paragraph야(frontend/src/__tests__/fixtures/spoken-units.json, 값은 false). 화면의 READS_BY_PARAGRAPH, 서버의 voice_turn.READS_BY_PARAGRAPH, 폰의 AppModel.readsByParagraph는 저마다 그 값에 대고 테스트해. 두 설정은 사례 목록 둘이 묶어 둬. whole_answer_cases는 화면과 서버의 기본 자르기를, cases는 스위치 뒤의 문단 자르기를 지켜. 사례 파일만 뒤집으면, 안 따라온 쪽은 전부 제 테스트에서 떨어져.

안 끊기는 것부터 맞춘 쿠션

두 번째 결정은 CwkKitCore.SpeechCushion의 숫자가 됐어(cwkSiblingKit 21507b4, 빌드 158). 첫 소리 전에 좋은 링크에선 소리 1.5초, 비싼 링크(셀룰러, 비싼 망, 제한된 망)에선 2.0초를 쥐어. 한 번 마른 읽기는 다음 읽기들에 0.5초를 보태고, 그걸 10분 기억해. 붐비는 셀은 답 하나가 아니라 몇 분씩 붐비거든. 무슨 일이 있어도 3.0초를 넘게 쥐진 않아. 멈춘 뒤 다시 채우는 양은 비싼 링크의 시작보다 작지 않아. 멈췄다는 것 자체가 비싼 링크라는 증거니까. 그리고 폰이 아직 이름을 못 댄 링크는 비싼 링크로 쳐. 빌드 157에선 앱을 켜고 첫 턴이 와이파이였는데도 링크가 link=unknown으로 잡혀서, 제일 작은 쿠션으로 시작했거든. 그래서 링크 감시는 이제 앱이 켜질 때 시작해. 그때 잰 합성 속도(실시간의 3~4배)로 치면, 쿠션을 깊게 한 값은 첫 단어 전 0.4~0.8초쯤이었어. 쿠션은 프로세스마다 하나라서, 한 읽기가 배운 건 다음 읽기로 넘어가. 대화 자체의 엔진(VoiceLoopSpeechPlayer)도 같은 규칙을 따라.

반대쪽은 서버가 재. 피파 머신의 [tts-take] 줄마다 need=가 붙어. 그 테이크 하나만 놓고, 첫 바이트부터 한 번도 안 마르려면 필요했던 쿠션이야. 폰의 쿠션보다 더 필요했으면 그 끊김은 합성 탓이고, 덜 필요했으면 망 탓이야. 아래 스케치는 2초 동안 처지는 링크에 쿠션을 걸어 봐.

목소리에도 마감이 있어

쿠션은 느린 링크를 위한 답이야. 죽은 링크엔 아무 소용이 없고, 통째 읽기에선 단위 하나가 답 전체일 수도 있어. 2026-10-22의 음성 모드 리뷰가 목소리에 마감을 달았어. 15초 안에 첫 바이트가 없는 테이크는 504로 답하고, 그 합성은 버려. 그래야 다시 청할 때 멈춘 합성에 붙지 않고 새로 시작하거든. Bellows 스트림엔 320초짜리 한도 하나 대신 청크 사이 30초 제한을 걸어. Bellows가 중간에 끊은 테이크는 깨진 전송으로 끝나(TakeCutShort, 로그엔 [tts-take] … failed short_at=). 짧지만 온전한 답으로 끝나는 일은 절대 없어. 재생기는 잘린 테이크와 피파가 말을 마친 걸 구별 못 하니까. 그리고 20초 동안 소리를 하나도 못 얻은 멈춤(SpeechCushion.holdDeadline)은 읽기를 .stalled로 끝내. 핸즈프리 대화에선 폰이 먼저 같은 테이크를 다시 청해서 들린 자리부터 이어 읽어. 두 번째로 실패해야 비로소 말하고("Her voice stopped early") 보고해. 그 리뷰의 나머지는 트랙 6에 새로 생긴 "예외의 길" 레슨이 따라가.

얻은 것, 그리고 치르는 값

제대로 잰 첫 숫자는 피파 머신이 옮겨 간 뒤에 나왔어. 2026-10-21~22의 폰 음성 턴 103개(그중 90개가 셀룰러)가 전부 underruns=0이었고, 0.3초 넘는 틈은 딱 하나였어. 끊김은 사라졌어. 값도 그 옆에 적혀 있어. 아빠 말이 끝나고 피파의 첫 소리까지, 받아쓴 턴은 중앙값 31.0초(p90 47.5초)였어. 그중 13초쯤은 생각을 켠 모델의 첫 글까지였고, 중앙값 5.7초는 done을 기다린 시간이야. 통째 읽기 그 자체지. 단위에서 첫 소리까지는 3.4초였어. 리뷰는 큰 두 덩어리를 결함이 아니라 일부러 고른 방식이라고 했어. 5.7초는 끊김 0과 맞바꾼 아빠의 거래야. 아빠는 숫자를 눈앞에 두고 그 거래를 했어.

그러니 예전에 '기본은 빠르게, 원하면 깊게'가 그랬던 것처럼, 이 퀘스트의 v5도 지워진 게 아니라 뒤로 물러난 거야. 이번에 얻은 건 듣는 사람이 귀로 다시 고른 품질 기본값, 도로 빠진 예외, 그리고 자르는 쪽 전부를 스위치 하나에 대 보는 테스트야. 그 말라가의 밤에선 실이 하나 더 갈라져 나왔어. 한 번도 맞은 적 없던 "Reconnecting" 이야기야. 그건 트랙 10의 새 레슨 "잘못된 경보, 원인마다 따로 재기"가 이어 가.

Code

실행되는 스케치. CwkKitCore.SpeechCushion(cwkSiblingKit)을 본떠서, 쿠션과 멈춘 뒤 다시 채우기와 20초 멈춤 마감을 2초 동안 처지는 링크에 걸어 봐·python
# A sketch of CwkKitCore.SpeechCushion (cwkSiblingKit): how much of a streamed
# take is held before her first sound, and again after the speaker ran dry.
GOOD, COSTLY, GROWTH, CEILING, MEMORY = 1.5, 2.0, 0.5, 3.0, 600   # seconds
RECOVERED, HOLD_DEADLINE, TICK = 1.5, 20.0, 0.05


class Cushion:
    """One per process, so what one reading learned holds for the next."""

    def __init__(self, good: float = GOOD) -> None:
        self.good, self.learned, self.learned_at = good, 0.0, None

    def taught(self, now: float) -> float:
        fresh = self.learned_at is not None and now - self.learned_at < MEMORY
        return self.learned if fresh else 0.0

    def start(self, link: str, now: float = 0.0) -> float:
        base = COSTLY if link in ("cellular", "unknown") else self.good   # also expensive, constrained
        return min(CEILING, max(base, self.taught(now)))

    def refill(self, link: str, now: float = 0.0) -> float:
        return min(CEILING, max(COSTLY, self.start(link, now)))           # a stall proves it

    def record(self, stalls: int, held: float, now: float = 0.0) -> None:
        if stalls:
            base = max(held, self.taught(now), COSTLY)
            self.learned, self.learned_at = min(CEILING, base + GROWTH), now


def sagging_link(length: float, legs: list[tuple[float, float]]) -> list[float]:
    """Audio seconds on hand at each tick, for a link given as (until wall second,
    x real time) legs: here 1.3x, then two seconds of a cell sagging to 0.4x."""
    got, t, out = 0.0, 0.0, []
    while got < length:
        rate = next((r for until, r in legs if t < until), legs[-1][1])
        got = min(length, got + rate * TICK)
        t += TICK
        out.append(got)
    return out


def play(on_hand: list[float], cushion: Cushion, kind: str) -> tuple[float, int, float]:
    """First sound once the start is on hand; a dry speaker holds once and refills."""
    start, length = cushion.start(kind), on_hand[-1]
    on_hand = on_hand + [length] * int(HOLD_DEADLINE / TICK)
    played, first, stalls, silent, held_at, quiet_at = 0.0, None, 0, 0.0, None, 0.0
    for tick, got in enumerate(on_hand):
        now = (tick + 1) * TICK                       # seconds since the first byte
        if first is None:
            if got >= min(start, length):
                first = now
            continue
        if held_at is not None:                       # holding after a stall
            gained, waited = got - on_hand[held_at], now - (held_at + 1) * TICK
            if got > on_hand[tick - 1]:
                quiet_at = now                        # audio arrived: the deadline restarts
            if got >= length or gained >= cushion.refill(kind) or (
                    gained >= cushion.good and gained >= RECOVERED * waited):
                silent, held_at = silent + waited, None
            elif now - quiet_at >= HOLD_DEADLINE:     # no audio gained for 20 s, not 20 s held
                raise TimeoutError("stalled: reopen the take at the heard position")
            continue
        played = min(got, played + TICK)
        if played >= length:
            break
        if played >= got:
            stalls, held_at, quiet_at = stalls + 1, tick, now
    cushion.record(stalls, start)
    return round(first, 2), stalls, round(silent, 2)


take = sagging_link(8.0, [(2.0, 1.3), (4.0, 0.4), (99, 2.0)])   # an 8 s take
for good in (0.35, 1.5):
    first, stalls, silent = play(take, Cushion(good), "wifi")
    print(f"good-link cushion {good:.2f} s: first sound {first} s, stalls {stalls}, silent {silent} s")

cushion = Cushion()
print("a link not known yet starts at", cushion.start("unknown"), "s")
cushion.record(stalls=1, held=1.5, now=0)
print("after a stall:", cushion.start("wifi", now=60), "s; eleven minutes later:", cushion.start("wifi", now=660), "s")

# Output:
# good-link cushion 0.35 s: first sound 0.3 s, stalls 1, silent 1.45 s
# good-link cushion 1.50 s: first sound 1.2 s, stalls 0, silent 0.0 s
# a link not known yet starts at 2.0 s
# after a stall: 2.5 s; eleven minutes later: 1.5 s
원문 그대로, backend/services/voice_turn.py (cwkPippa), 2026-10-27 기준: 서버 쪽에 있는 스위치 하나·python
# Whether a finished answer is read by paragraph (Dad, 2026-10-19, todo #556:
# whole is the default on every surface). The one value lives in the shared
# fixture (frontend fixtures/spoken-units.json ``reads_by_paragraph``); this
# constant, the screen's ``READS_BY_PARAGRAPH`` and the phone's
# ``AppModel.readsByParagraph`` are each tested against it.
READS_BY_PARAGRAPH = False
원문 그대로, backend/tests/test_voice_turn.py (cwkPippa), 2026-10-27 기준: 서버를 사례 파일에 대 보는 테스트와, 묶어 둔 기본 자르기·python
    def test_follows_the_one_reading_switch(self):
        # Dad, 2026-10-19 (todo #556): whole answers on every surface, one switch.
        assert voice_turn.READS_BY_PARAGRAPH is _SPOKEN_UNIT_FIXTURE["reads_by_paragraph"]

    @pytest.mark.parametrize("case", _WHOLE_ANSWER_CASES, ids=lambda case: case["name"])
    def test_reads_whole_as_the_screen_reads(self, case):
        assert voice_turn.spoken_units(case["content"], case["unit_ends"]) == case["units"]

External links

Exercise

스케치를 돌려서 네 줄을 읽어 봐. 그다음엔 돌리기 전에 결과부터 맞혀. 첫째, play에 링크를 "unknown"으로 주고 좋은 링크 쿠션 두 값으로 다 돌려. 둘째, 처지는 구간을 0.2배로 4초 동안 깊게 만들고, 다음 읽기가 몇 초로 시작할지 쿠션한테 물어봐. 셋째, 가운데 구간을 0.0배로 25초 동안 두고 TimeoutError를 잡아서 다시 열기를 써. 같은 테이크를 한 번 더 틀되 이미 들린 초는 건너뛰고, 그것마저 실패할 때만 보고하는 거야. 마지막으로 네 프로젝트에 스위치 테스트를 써 봐. 사례 파일의 값 하나, 구현마다 단언 하나.
Hint
링크가 unknown이면 두 번 다 2.0초로 시작해. 비싼 링크의 바닥이 좋은 링크 값을 이기거든. 0.2배로 처지면 1.5초 쿠션도 한 번 멈추고, 다음 읽기는 2.5초로 시작해. 10분 기억이 바로 그걸 위해 있어. 마감은 쥐고 있던 초가 아니라 새 소리가 하나도 안 온 초를 세. 그래서 찔끔찔끔이라도 계속 오면 마감에 안 걸려. 다시 열 때 들린 위치는 받은 초가 아니라 재생된 초로 세. 도착했어도 소리가 안 난 건 들린 적이 없어. 스위치 테스트는 이 레슨에서 제일 싼 한 줄이고, 폰은 통째로 읽는데 서버는 아직 문단으로 자르던 걸 잡았을 바로 그 줄이야.

Progress

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

댓글 0

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

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