~13 min · end-of-speech, manual-commit, timeouts, mobile-network
Level 0음소거
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"외부에서는 잡음이 많고 소음이 많으니까 굉장히 오래 기다려 ... 보이스 모드에 Send Now 같은 버튼이 있어야 할 것 같아" — 아빠, 2026-10-09
침묵이 끝내 안 올 때
이 트랙이 지금까지 만든 건 전부 턴을 같은 식으로 끝내. 아빠가 말을 멈추고, 방이 조용해지고, Scribe가 침묵 기준만큼 기다렸다 커밋하고, 루프는 아빠 창의 나머지를 기다려. 책상 앞에선 좋은 규칙이야. 길에선 한 번도 안 울려. 차 소리, 바람, 지나가는 버스는 침묵으로 안 읽혀. 그러니 Scribe는 커밋을 안 하고, 3초는 세기 시작조차 안 해. 아빠는 문장을 다 끝내 놓고도, 길이 끝내 내주지 않을 틈을 기다리며 서 있었어.
솔깃한 해법은 다 짐작이야. 침묵 기준을 낮추면 실내에선 숨 한 번 고를 때마다 턴이 끊겨. 방의 잡음에 맞춰 기준을 움직이면, 길을 흉내 내는 모델을 하나 두고 튜닝하고 또 의심해야 해. 대신 아빠는 단순한 걸 달라고 했어. 다 말했어라고 알려주는 버튼. 소음에 맞춰 늘었다 줄었다 하는 창은 아직 안 만들었어. 길 문제는 버튼이 먼저 풀었거든.
손으로 하는 커밋
Scribe 실시간 소켓엔 이미 그 수가 있어. 클라이언트가 소리 없는 오디오 메시지에 commit: true를 붙여 보내면, vad 전략으로 도는 세션도 그걸 받아줘. Scribe는 그 자리에서 조각을 닫고 단어로 답해. 시험해 보니 답은 0.23초 만에 왔어. 그래서 Send now는 Scribe에게 지금까지 들은 걸 확정하라고 하고, 그 단어가 도착하는 순간 턴을 보내. 이 트랙 세 번째 레슨의 창 기다리기도, 네 번째 레슨의 강제 커밋 검사도 일부러 건너뛰어. 방이 얼마나 시끄럽든, 누른 순간이 아빠 말의 끝이니까.
경계 사례 셋은 따로 이름을 붙여서 다뤄. 첫째, 침묵 커밋 뒤에 루프가 아빠 창의 나머지를 기다리는 중에 누르면, 더 물어볼 게 없으니 턴이 바로 나가. 둘째, 들은 게 하나도 없는데 커밋도 빈손으로 돌아오면 계속 들어. 폰은 화면에 그렇다고 말해줘(Nothing heard yet to send. Pippa is still listening.). 셋째, 단어가 비어 버린 턴은 절대 안 나가. 폰에선 이유를 말하고 다시 들어.
귀를 기다리지 마
첫 버전은 커밋의 단어를 기다렸고, 하루 만에 깨졌어. 약한 모바일 연결에선 소켓의 소리가 나가는 길에 줄을 서고, Scribe의 단어는 아빠보다 몇 초씩 늦게 따라와. 그러니 버튼을 눌러도 손에 쥔 단어가 없었어. 누른 건 똑같은 줄에 갇힌 커밋을 기다리다가 저 혼자 흐지부지됐고, 화면엔 아무 변화도 없었어. 아빠의 보고는 짧았어. "Send now 리그레션. 먹통이야." 그 턴의 녹음을 보면 얼마나 심했는지 나와. 말은 3.9초에 끝났는데 턴은 21.6초에 나갔어. 그날 받아쓴 다른 턴들은 꼬리가 다 3.5초쯤이었는데, 이 턴은 17.7초였던 거야.
2026-10-10부터 규칙은 이래. 귀는 1.5초 넘게 안 기다려. 폰 버튼엔 누르는 순간 Sending…이 떠서, 누른 게 허공으로 사라진 것처럼 보일 일이 없어. 커밋이 제때 답하지 않으면 턴은 그래도 끝나. 폰은 녹음 전체를 이미 쥐고 있으니 그걸 배치 길로 통째로 받아쓰고, 그마저 실패하면 실시간 단어가 나가. 웹은 실시간으로 들은 걸 보내. 확정된 조각과 마지막 중간 전사(partial)야. 들은 게 하나도 없으면 그냥 계속 들어. 30초 이상인 발화는 거기서도 늘 하던 대로 통째로 다시 받아써. 어느 쪽이든 느린 쪽이 단어를 늦게 만들 순 있어도, 누른 걸 없던 일로 만들 순 없어. 고쳤다는 말은 아빠가 자기 폰으로 확인한 다음에야 했어.
문마다 버튼이 있어
WebUI는 피파가 듣는 동안 얼굴 아래에 넓은 청록색 Send now를 띄우고, Enter도 같은 일을 해. 다만 Enter를 가로채는 건 듣는 중이고, 조합키를 안 눌렀고, 아빠가 입력칸에 타이핑하거나 한글을 조합하는 중이 아닐 때뿐이야. 나머지 경우엔 Enter를 건드리지 않아.
폰에도 얼굴 아래에 같은 버튼이 있어(아래 스크린숏). 헤드폰의 재생/일시정지 버튼 누르기도 연결돼 있어. 피파가 듣는 동안엔 Send now, 말하는 동안엔 끼어들기야. 다만 iOS는 녹음하는 앱에 에어팟 전용 동작을 안 줘. 녹음만 하고 아무것도 재생하지 않는 대화에서도 그 누르기가 먹히는지는 아빠 에어팟으로 직접 확인해 봐야 알아. 화면의 버튼은 거기에 기대지 않아.
워치는 듣는 중에 고리를 탭하면 침묵을 기다리지 않고 보내. 워치가 어쩌다 한 번 누르면 한 턴만 받게 됐는지는 트랙 6에서 들려줄게.
Code
웹의 Send now. 손으로 하는 커밋 하나, 그리고 1.5초 넘게는 절대 안 기다리는 마감·typescript
// frontend/src/hooks/useHandsFree.ts (cwkPippa), verbatim
const sendNow = useCallback((): boolean => {
if (phaseRef.current !== 'listening' || !captureRef.current) return false;
if (sendNowRef.current !== null) return true;
const socket = socketRef.current;
if (windowWaitRef.current !== null && segmentsRef.current.length && !partialRef.current) {
// Scribe already committed after silence and the loop is waiting out the
// rest of Dad's window: nothing more to ask for.
void finishTurn();
return true;
}
sendNowRef.current = window.setTimeout(() => {
sendNowRef.current = null;
if (phaseRef.current !== 'listening') return;
// The commit's words never came: what was heard live goes out.
const partial = partialRef.current.trim();
if (partial) segmentsRef.current.push(partial);
if (segmentsRef.current.length) void finishTurn();
}, SEND_NOW_WAIT_MS);
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: '', commit: true, sample_rate: SAMPLE_RATE }));
}
return true;
}, [finishTurn]);
최소 스케치. 누르기, 커밋, 그리고 귀가 늦을 때 생기는 일·python
import asyncio
SEND_NOW_WAIT = 1.5 # seconds: never wait on the ear longer than this
async def send_now(scribe_commit, heard_live: list[str], partial: str, *, phone: bool) -> str:
"""Dad pressed Send now: ask for a manual commit, never wait on it for long."""
try:
words = await asyncio.wait_for(scribe_commit(), SEND_NOW_WAIT)
except asyncio.TimeoutError:
# The commit is late, so the live words are late too.
if phone:
return "turn ends; the recording on the phone is transcribed whole"
live = heard_live + ([partial] if partial else [])
if not live:
return "keep listening: nothing heard yet to send"
return f"turn goes out with what was heard live: {' '.join(live)!r}"
pieces = heard_live + ([words] if words else [])
if not pieces:
return "keep listening: nothing heard yet to send"
return f"turn goes out: {' '.join(pieces)!r}"
def scribe(after: float, words: str):
async def commit() -> str:
await asyncio.sleep(after) # the socket's queue, then Scribe's answer
return words
return commit
async def main() -> None:
# A quick link: the manual commit answers in about a quarter-second.
print(await send_now(scribe(0.23, "and that's the plan"), ["Okay,"], "and that's", phone=False))
# A weak mobile link: the socket's audio is queued and the answer is late.
print(await send_now(scribe(6.0, "and that's the plan"), ["Okay,"], "and that's", phone=False))
print(await send_now(scribe(6.0, "and that's the plan"), ["Okay,"], "and that's", phone=True))
# A press before a single word: Scribe commits nothing over nothing.
print(await send_now(scribe(0.2, ""), [], "", phone=True))
# The same empty press on the web, with the commit stuck behind the queue.
print(await send_now(scribe(3.0, ""), [], "", phone=False))
asyncio.run(main())
# Output:
# turn goes out: "Okay, and that's the plan"
# turn goes out with what was heard live: "Okay, and that's"
# turn ends; the recording on the phone is transcribed whole
# keep listening: nothing heard yet to send
# keep listening: nothing heard yet to send
스케치를 돌려본 다음, 약한 연결의 지연을 6.0초에서 1.0초로 바꾸고 줄마다 결과를 먼저 예측한 뒤 다시 돌려봐. 그다음 네 음성 루프(아니면 이 스케치)에 아직 없는 문을 하나 더해. 턴을 끝내는 키야. 그 키를 무시해야 할 때가 언제인지 정하고, 경우마다 테스트를 하나씩 써.
Hint
1.0초면 커밋이 1.5초 마감보다 먼저 와서, 약한 연결 두 줄이 다 문장 전체를 담은 평범한 전송으로 바뀌어. 키를 다룰 땐 WebUI 규칙이 좋은 점검표야. 듣는 중일 때만, 조합키를 누르고 있지 않을 때만, 초점이 입력칸에 있지 않을 때만, 그리고 IME 조합 중이 아닐 때만. 조합 중의 Enter는 턴이 아니라 한글 음절 하나를 끝내거든. 키를 꾹 눌러 반복 입력이 들어와도 한 번만 눌린 걸로 쳐야 해.
Progress
Progress is local-only — sign in to sync across devices.