"지금은 마이크를 잠깐 중단할 방법이 없어. 피파랑 대화하다가 잠깐 마이크 끄고 엄마랑 대화하려면 껐다 다시 들어와야 하는 식이야." — 아빠, 2026-09-27
있긴 했던 일시정지
소울 얼굴을 탭하면 원래도 듣기가 멈췄어. 그런데 듣는 중일 때만 됐고, 마이크는 열어 둔 채였고, 그렇다고 알려주는 곳은 툴팁 하나뿐이었어. 아빠 입장에선 대화를 끝내지 않고 1분만 빠져나올 방법이 없었어. 끝내면 녹음 불빛도, 세션도, 얼굴 화면도 전부 사라졌다가 처음부터 다시 켜야 했어. 아빠는 음성이 있는 모든 화면에 진짜 일시정지를 달라고 했어. WebUI, 폰, Firekeeper.
지금의 일시정지
일시정지는 화면을 떠나지 않은 채 '잠깐 나갔다 오기'고, 어느 단계에서든 돼.
마이크를 놓아줘. 그래서 녹음 불빛이 꺼져. 마이크를 열어 둔 일시정지는 아무도 믿지 않아.
읽던 게 있으면 멈추고, 엔진은 아빠가 어디까지 들었는지 알게 돼. 끼어들었을 때와 똑같이.
아직 오고 있던 답의 나머지는 완전히 건너뛰어. 글은 대화에 남고, 아빠의 다음 턴이 건너뛰기를 풀어.
다시 시작할 때까진 아무것도 안 들어.
이미 끝나서 올라가는 중인 발화는 그대로 나가. 일시정지가 아빠가 이미 한 말을 먹어버리는 일은 없어.
이 마지막 약속엔 구멍이 하나 있고, 이 퀘스트를 쓰는 지금도 열려 있어. 듣기를 시작할 때마다 새 세대 번호를 찍어서, 예전 듣기의 늦은 결과가 새 듣기에 끼어들지 못하게 해. 다시 시작도 듣기를 시작해. 그래서 멈춘 동안 올라가던 업로드가 끝나기 전에 아빠가 다시 시작하면, 그 업로드는 옛 세대 번호를 달고 돌아와서 낡은 결과로 버려져. 소리는 남지만 턴은 안 나가. 일시정지만으론 안전하고, 멈췄다가 바로 다시 시작하는 게 경주야. 세대 번호는 옛 결과를 막으려고 있는데, 멈춘 동안의 업로드가 바로 옛 결과거든.
다시 시작하면 바로 들어. 그리고 얼굴 탭은 단계마다 뜻이 분명해. 듣거나 생각하는 중엔 일시정지, 멈췄거나 끝난 상태에선 다시 시작, 피파가 말하는 중엔 끼어들기.
설정할 수 있고, 방해는 안 되는 단축키
같은 날 다음 부탁이 왔어. 단축키, 바꿀 수 있게, 웹과 Firekeeper에선 기본이 스페이스. 웹에선 Admin의 집 설정이야. 키 코드 하나, 조합키를 앞에 붙이거나 말거나, 아니면 끔. 서버가 정규화해서 지저분한 값이 페이지까지 못 가. 어려운 건 키를 맞추는 게 아니야. 그 키가 다른 뜻일 땐 절대 안 맞추는 거지.
아빠가 입력칸에 타이핑하는 중엔 절대 안 움직여.
입력기가 글자를 조합하는 중엔 절대 안 움직여. 한국어에선 이게 엄청 중요해. 한글 음절을 조합하는 중의 스페이스는 입력기 거야.
짝이 되는 keyup도 같이 삼켜. 그래야 포커스를 가진 버튼이 같은 키를 클릭으로 읽어서 일시정지를 한 번 더 뒤집지 않아. 꾹 누른 키가 자동으로 반복돼도 한 번 누른 거야.
얼굴 화면은 자기가 덮는 입력칸에서 키보드 포커스를 가져와. 핸즈프리 전에 포커스를 쥐고 있던 입력창이 스페이스를 타이핑한 공백으로 받아버리지 않게.
Admin은 '바꾸기' 버튼으로 새 키를 녹화하고, '끄기'와 '기본값'도 있어. 조합키 하나만은 웹에서 녹화할 수 없어. Command-Tab 뒤에 Command를 뗐다는 걸 브라우저가 전해 듣지 못하거든. 네이티브 맥 앱인 Firekeeper는 할 수 있고, 실제로 써. 거기선 왼쪽 Command를 혼자 톡 누르면 끼어들고, 스페이스는 일시정지야.
사용자처럼 확인했어
브라우저 창에서 일시정지를 누르니 가짜 마이크의 트랙이 ended로 바뀌고 "Paused. The microphone is off."와 다시 시작 버튼이 떴어. 다시 시작하니 살아 있는 트랙에서 새 듣기 세션이 열렸어. 중요한 확인은 바로 이거야. 플래그가 뒤집혔는지가 아니라, 마이크가 진짜로 손을 놨는지.
Code
타이핑, 입력기, 포커스와 절대 안 싸우는 일시정지 키·typescript
// A configurable pause key that never fights with typing, IME or focus.
interface KeyLike {
code: string; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean;
isComposing: boolean; keyCode?: number; repeat: boolean; targetIsEditable: boolean;
}
/** "Space", "Alt+KeyP", "Meta+Shift+KeyM", or "disabled". Normalized on save. */
export function normalizePauseKey(raw: string | null | undefined): string {
const parts = (raw ?? '').split('+').map((p) => p.trim()).filter(Boolean);
if (!parts.length) return 'Space';
if (parts.length === 1 && parts[0].toLowerCase() === 'disabled') return 'disabled';
const order = ['Ctrl', 'Alt', 'Shift', 'Meta'];
const mods = order.filter((m) => parts.slice(0, -1).some((p) => p.toLowerCase() === m.toLowerCase()));
return [...mods, parts[parts.length - 1]].join('+');
}
export function matchesPauseKey(event: KeyLike, setting: string): boolean {
if (setting === 'disabled') return false;
if (event.targetIsEditable) return false; // typing in a field
if (event.isComposing || event.keyCode === 229) return false; // Hangul IME mid-syllable
const parts = setting.split('+');
const code = parts[parts.length - 1];
const want = new Set(parts.slice(0, -1));
return event.code === code
&& event.ctrlKey === want.has('Ctrl') && event.altKey === want.has('Alt')
&& event.shiftKey === want.has('Shift') && event.metaKey === want.has('Meta');
}
// Swallow the matching keyup too, so a focused button can't read the same
// key as a click and toggle pause a second time. Only THAT key's keyup:
// an unrelated key released in between is none of our business.
let swallowKeyUp: string | null = null;
let paused = false;
function onKeyDown(event: KeyLike, setting: string) {
if (!matchesPauseKey(event, setting)) return;
swallowKeyUp = event.code;
if (event.repeat) return; // a held key is one press, not a drumroll
paused = !paused;
console.log(paused ? 'Paused. The microphone is off.' : 'Listening again.');
}
function onKeyUp(event: KeyLike) {
if (swallowKeyUp !== event.code) return;
swallowKeyUp = null;
console.log(` (keyup ${event.code} swallowed)`);
}
const key = (code: string, extra: Partial<KeyLike> = {}): KeyLike => ({
code, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
isComposing: false, repeat: false, targetIsEditable: false, ...extra,
});
const setting = normalizePauseKey('Space'); // Admin's house setting
onKeyDown(key('Space'), setting); onKeyUp(key('Space'));
onKeyDown(key('Space', { isComposing: true }), setting); // ignored: composing
onKeyDown(key('Space', { targetIsEditable: true }), setting); // ignored: typing
onKeyDown(key('Space'), setting);
onKeyDown(key('Space', { repeat: true }), setting); // held: no second toggle
onKeyUp(key('KeyA')); // not ours: passes through
onKeyUp(key('Space'));
console.log(normalizePauseKey('shift + alt + KeyP'), normalizePauseKey('DISABLED'));
npx tsx로 코드를 돌려서 출력을 읽어봐. 그다음 두 경우를 넣어. keyCode는 229인데 isComposing은 false로 오는 키(그러는 브라우저가 있어), 그리고 설정이 'disabled'인 경우. 마지막으로 웹 음성 화면의 일시정지 함수 자체를 써. 마이크 스트림의 트랙을 전부 멈추고, 어디까지 들었는지 알리면서 재생을 멈추고, 답의 나머지를 건너뛴 걸로 표시하는 거야.
Hint
마이크는 getUserMedia로 받은 스트림의 트랙마다 stop()을 불러야 놓여나고, 트랙의 readyState가 'ended'가 되는 게 증거야. 들은 위치는 재생을 멈추기 전에 알려. 멈춘 뒤엔 오디오 요소가 초기화돼서 currentTime이 사라져.
Progress
Progress is local-only — sign in to sync across devices.