"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의 새 레슨 "잘못된 경보, 원인마다 따로 재기"가 이어 가.