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

녹음은 절대 버리지 않아

~14 min · reliability, routing, exactly-once, debugging

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"voice mode: a recording is never held silently" — 그걸 고친 커밋, 2026-09-25

15초, 받아쓰고, 보관하고, 안 보냄

음성 모드가 켜지고 한 시간도 안 돼서, 아빠가 WebUI에서 한국어로 15.6초를 녹음했어. 녹음은 받아쓰여서 보관됐어. 그런데 턴이 이어지지 않았어. 아빠는 전송 방지 장치가 먹어버린 거냐고 물었어. 아니었어. 그 장치는 입력창의 Enter 키에만 걸려 있고, 음성 전송은 거길 절대 안 지나가. 같은 빌드에 가짜 마이크를 물리면 새로 열든 좀 기다렸다 하든 매번 보내졌어. 그러니까 코드 어딘가에 녹음을 아무 말 없이 붙잡아 두는 길이 있어야 했는데, 둘이나 있었어. 답이 아직 흘러나오는 중이면 말이 입력창으로 가서 그대로 앉아 있었어. 그리고 전송 함수는 다른 전송이 이미 진행 중이면 거절한 전송을 조용히 버렸어.

결과는 셋, 넷째는 없어

고친 코드는 끝난 녹음이 갈 수 있는 곳을 전부 이름 붙였고, 딱 셋이야. 지금 보내기. 아직 흘러나오는 답 뒤에 줄 세우기, 그리고 그 스트림이 끝나는 순간 보내기. 그 사이에 두 번째 녹음이 오면 첫 번째에 합쳐서, 두 숨이 한 턴이 돼. 아니면 입력창에 넣기. 이건 확인 모드가 켜졌거나 음성 모드가 꺼졌을 때만이야. 음성 모드 밖에선 마이크가 절대 스스로 보내지 않으니까. 보낼 수 있는지는 맨 먼저 물어보고, 보내기가 시작도 못 하면 사라지는 대신 메모와 함께 입력창에 떨어져. 놓이는 건 턴이야. 글, 그리고 업로드가 성공했으면 그 옆의 녹음 업로드 id(실패한 경우는 트랙 2의 마지막 레슨에서 다뤄). 컨트롤은 녹음이 왜 기다리는지 말해줘. "확인이 켜져 있어", 아니면 "줄 서 있어". 확인 토글도 음성 토글처럼 읽히게 다시 그렸어. 채워져 있으면 켜진 거야. 예전의 테두리만 있는 모양은 꺼진 걸로 읽혔거든.

규칙이 하나 더 있어서 아빠 말을 소울 목소리로부터 지켜. 아빠가 녹음을 시작한 순간부터 말이 보내지거나 입력창에 놓일 때까지, 그 사이에 완성되는 답의 단위는 시작하지 않고 건너뛰어. 아빠가 말하면 소울은 조용해.

폰 버전: 딱 한 번, 늦게는 절대

폰은 반대 방향으로 답을 잃었어. 녹음 둘이 나갔고 두 답 다 말하는 답으로 표시됐는데, 아빠가 탭할 때까지 아무것도 재생되지 않았어. 말하는 답 확인이 시계 하나를 읽었는데, 스트림 정리 과정이 그 시계를 지웠고, 스트림이 닫히는 바로 그때 푸시 알림이 두 번째 정리를 깨워서 둘이 경주를 벌였어. 고친 방법은 시계 대신 작은 관문이야. 말하는 턴은 보낼 때 기대로 등록돼. 답을 먼저 본 쪽(흘러나온 스트림을 비우는 쪽이든, 로컬 기록을 정리하는 새로고침이든)이 그걸 차지해서 읽어. 딱 한 번. 순간이 지나버린 답은(음성이 꺼졌거나, 다른 대화가 열렸거나, 아빠가 이미 다시 말하는 중이면) 버리고 절대 늦게 재생하지 않아. 엉뚱한 순간에 읽히는 답은 아예 안 읽히는 답보다 나빠. 관문은 경주를 없앴지만 침묵을 전부 없애진 못했어. 긴 답은 트랙 4에서 이야기한 타임아웃을 찾을 때까지, 빌드 두어 개 동안 폰에서 계속 조용했어.

녹음은 옆에서 기다리던 것도 데려가

아빠의 다음 보고. 사진을 첨부하면 말로 한 턴이 안 보내진대. 이제 전사문은 입력창에 이미 있는 것과 합쳐지고, 입력창 전체가 한꺼번에 나가. 타이핑한 말, 사진, 녹음의 사실까지.

Code

길 셋, 합쳐지는 줄, 그리고 딱 한 번 관문·typescript
// Where a finished recording goes, and who gets to play a spoken reply.
type Route = 'send' | 'queue' | 'composer';

export function routeTranscript(s: { voiceTurns: boolean; confirmBeforeSend: boolean; sendBusy: boolean }): Route {
  if (!s.voiceTurns || s.confirmBeforeSend) return 'composer';   // Dad reviews first
  return s.sendBusy ? 'queue' : 'send';                          // never a fourth outcome
}

interface Queued { text: string; audioIds: string[] }

/** One waiting turn per conversation: a second recording joins the first,
 *  and a recording for another conversation waits beside it, never over it. */
export function enqueue(queue: Map<string, Queued>, conversationId: string, next: Queued): void {
  const waiting = queue.get(conversationId);
  queue.set(conversationId, waiting
    ? { text: `${waiting.text.trimEnd()} ${next.text.trim()}`, audioIds: [...waiting.audioIds, ...next.audioIds] }
    : next);
}

/** A spoken reply is read exactly once, by whichever path sees it first,
 *  and never late: a reply whose moment has passed is dropped. */
export class SpokenReplyGate {
  private expected = new Set<string>();
  expect(requestId: string) { this.expected.add(requestId); }
  claim(requestId: string, moment: { voiceOn: boolean; sameConversation: boolean; dadTalking: boolean }): string {
    if (!this.expected.delete(requestId)) return 'skip: already read, or never expected';
    if (!moment.voiceOn) return 'drop: voice was turned off';
    if (!moment.sameConversation) return 'drop: another conversation is open';
    if (moment.dadTalking) return 'drop: Dad is already talking';
    return 'play';
  }
}

console.log(routeTranscript({ voiceTurns: true, confirmBeforeSend: false, sendBusy: false }));
console.log(routeTranscript({ voiceTurns: true, confirmBeforeSend: false, sendBusy: true }));
console.log(routeTranscript({ voiceTurns: true, confirmBeforeSend: true, sendBusy: false }));

const queue = new Map<string, Queued>();
enqueue(queue, 'c1', { text: '내일 일정 알려주고', audioIds: ['u1'] });
enqueue(queue, 'c1', { text: '날씨도 봐줘', audioIds: ['u2'] });
enqueue(queue, 'c2', { text: '다른 얘기', audioIds: ['u3'] });   // kept, not overwritten
console.log(Object.fromEntries(queue));

const gate = new SpokenReplyGate();
gate.expect('turn-7');
const now = { voiceOn: true, sameConversation: true, dadTalking: false };
console.log(gate.claim('turn-7', now));   // the stream's drain sees it first
console.log(gate.claim('turn-7', now));   // the reload path sees it second

External links

Exercise

npx tsx로 코드를 돌려봐. 그다음 빠진 조각을 써. 스트림이 끝나면 열린 대화의 줄 선 턴이 있을 때 보내고 그 항목을 지우는 flush 함수, 그리고 답 하나가 흘러나오는 동안 만든 녹음 둘이 오디오 id 둘을 단 한 턴으로 도착하는지 확인하는 테스트. 마지막으로 줄 선 턴이 지금 열린 대화와 다른 대화의 것일 때도 넣어.
Hint
다른 대화의 줄 선 턴을 열린 대화로 보내면 안 돼. 자기 대화로 보내든, 거기 입력창에 메모와 함께 돌려놓든 해. 절대 해선 안 되는 건 사라지는 거야. 줄은 이미 대화별로 나뉘어 있어서, 다른 대화의 녹음은 앞의 녹음을 밀어내지 않고 그 옆에서 기다려. 같은 규칙을 반대쪽에서 본 거야.

Progress

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

댓글 0

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

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