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

멈추는 루프는 이유를 말해

~12 min · observability, ux, errors, feedback

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Listening 하고 빨간 불 하나만 있고 ... 다이내믹 표시가 전혀 없으니까 제대로 작동하는지를 알 수 없어" — 아빠, 2026-09-26

듣고는 있는 거야?

핸즈프리 루프는 눈에 보이는 게 거의 없어. 얼굴, 고리 하나, 단어 하나. 처음엔 듣는 상태가 빨간 점 하나였고, 아빠가 뻔한 문제를 짚었어. 움직이는 게 없으니 자기 목소리가 들리고 있는지 알 수가 없대. 그리고 바로 이어서, 빨강 말고 청록색으로 해달래. 무슨 경고등 같다고. 지금은 듣는 동안 얼굴 둘레의 고리와 그 아래 막대 일곱 개짜리 계량기가 아빠 목소리를 따라 청록색으로 움직여. 레벨은 소리의 RMS를 로그 눈금에 얹은 거라서, 조용한 방은 0에 머물고 평범한 말은 계량기 대부분을 채워. 소울이 생각하는 동안엔 고리가 회색이 되고 점 세 개가 뜨고, 말하는 동안엔 소울의 강조색으로 빛나. 단계마다 모습이 다 달라.

듣는 중인 웹 음성 화면. 청록색 고리 안의 피파 얼굴, 그 아래 청록색 막대 일곱 개짜리 계량기, 'Listening'이라는 글자와 일시정지 버튼. 맨 위엔 첨부 버튼, On Air, Cut in, Captions 토글, 그리고 KO 언어 표시.
웹에서 듣는 중. 청록색 고리와 일곱 막대 계량기가 아빠 목소리를 따라 움직여.

자기 화면을 닫아버린 에러

같은 날 아빠가 사이드킥의 루프가 KO를 누르자마자 "혼자 꺼진다"고 알려왔어. 조사해 보니 실패보다 더 나쁜 게 나왔어. 이유는 적히고 있었는데, 얼굴 화면에 적혔고, 실패를 처리하는 과정이 바로 그 화면을 닫아버렸어. 메시지는 아무도 볼 수 없는 화면에 찰나 동안만 있었던 거야.

그래서 두 클라이언트 다 stopped 단계를 얻었어. 거절, 닫힌 소켓, 조용한 1분이면 이제 얼굴이 이유를 띄운 채 그대로 있고, 탭 한 번이면 다시 들어. 대화를 끝내는 건 닫기 버튼뿐이야. 열리기도 전에 닫힌 소켓은 예전엔 단계를 연결 중에 묶어 뒀는데, 이제는 다른 닫힘과 똑같이 닫힘 코드와 이유를 띄우고 멈춰. 아빠가 겪은 원래 사례의 원인은 끝내 재현되지 않았어. 시험용 브라우저에서 진짜 전사기에 대고 두 번 눌렀더니 다 됐고, 엔진 로그엔 두 세션 다 허가됐다고 남았어. 괜찮아. 이 수정의 핵심은, 다음에 또 그러면 화면이 이유를 말한다는 거야.

말하게 만든 조용한 실패 둘 더

같은 원칙으로 둘을 더 고쳤어. 웹의 받아쓰기 시작 함수는 시작에 실패한 녹음기를 계속 쥐고 있어서, 그 뒤로 누를 때마다 바로 돌아가 버렸고 새로고침 전까지 마이크가 죽어 있었어. 이제 시작에 실패하면 마이크를 놓아주고 그렇게 말해. 그리고 폰에선, 일부러 건너뛴 답이 왜 건너뛰었는지(음성이 꺼졌다, 다른 대화가 열렸다, 아빠가 이미 말하는 중이었다)를 음성 칩 옆에 말하고, 재생에 실패하면 이유를 지우지 않고 남겨.

밑에 깔린 규칙

하나하나가 전부 코드는 알고 사용자는 모르는 상태였어. 음성 화면엔 되감기도, 콘솔도, 에러 페이지도 없어. 루프가 왜 멈췄는지 안다면, 얼굴이 그걸 말로 해야 하고, 읽을 수 있을 만큼 오래 떠 있어야 해.

Code

살아 있는 레벨 계량기, 그리고 화면에 남는 멈춤 이유·typescript
// Show that it's listening, and when it stops, keep the reason on screen.

/** A 16-bit RMS mapped to 0...1 on a log scale: a quiet room (~120) sits
 *  at 0 and ordinary speech (a few thousand) fills most of the range. */
export function levelFromRms(rms: number): number {
  if (!(rms > 0)) return 0;
  return Math.max(0, Math.min(1, Math.log10(Math.max(rms, 1) / 120) / Math.log10(40)));
}

/** Seven teal bars under the face: how much of the meter this level lights. */
export function meter(level: number, bars = 7): string {
  const lit = Math.round(level * bars);
  return '▮'.repeat(lit) + '▯'.repeat(bars - lit);
}

type Stop =
  | { cause: 'refused'; detail: string }
  | { cause: 'socket-closed'; code: number; reason: string; opened: boolean }
  | { cause: 'quiet-minute' }
  | { cause: 'mic-failed'; detail: string };

/** The face screen stays up in 'stopped' and shows this; a tap listens again. */
export function stopMessage(stop: Stop): string {
  switch (stop.cause) {
    case 'refused':
      return `Listening was refused: ${stop.detail}`;
    case 'socket-closed':
      return `Listening closed ${stop.opened ? '' : 'before it opened '}(${stop.code}${stop.reason ? `: ${stop.reason}` : ''})`;
    case 'quiet-minute':
      return 'Nothing heard for a minute, so listening stopped.';
    case 'mic-failed':
      return `The microphone didn't start and was released: ${stop.detail}`;
  }
}

for (const rms of [0, 120, 400, 1500, 4800, 12000]) {
  console.log(String(rms).padStart(6), meter(levelFromRms(rms)), levelFromRms(rms).toFixed(2));
}
console.log(stopMessage({ cause: 'socket-closed', code: 1008, reason: 'token expired', opened: false }));
console.log(stopMessage({ cause: 'quiet-minute' }));
console.log(stopMessage({ cause: 'mic-failed', detail: 'NotAllowedError' }));

External links

Exercise

코드를 돌려서 RMS 값이 계량기에 어떻게 옮겨지는지 봐. 실제 상황 셋(조용한 방, 작게 말하기, 평소처럼 말하기)을 골라 자기 마이크로 RMS를 재보고, 바닥값 120과 40배 범위가 자기 방에 맞는지 정해. 마지막으로 만들고 있는 음성 기능이 스스로 멈출 수 있는 경우를 전부 적고, 각각 띄울 한 줄 메시지를 써.
Hint
맞추기 전에 재. 한 방에 맞춰 고른 바닥값은 시끄러운 방을 계속 말하는 중으로, 조용한 방을 죽은 걸로 보이게 해. 멈춤 목록엔 절대 안 일어날 것 같은 것(열리기 전에 닫히는 소켓, 시작 못 하는 마이크)도 넣어. 안 넣으면 바로 그것들이 조용히 실패해.

Progress

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

댓글 0

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

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