"There's no way to switch off the mic for a moment. If I want to talk with Mom in the middle of a conversation with Pippa, I have to quit and come back." — Dad, 2026-09-27
A Pause That Existed, Barely
Tapping the soul's face already paused listening. But it worked only while listening, it left the microphone open, and the only place that said so was a tooltip. From Dad's side there was no way to step out of the talk for a minute without ending it, and ending it meant the recording light, the session and the face screen all going away, then coming back cold. He asked for a real pause on every voice surface: the WebUI, the phone and Firekeeper.
What Pause Means Now
Pause is "leave and come back" without leaving the screen, and it works in every phase:
The microphone is released, so the recording light goes out. A pause that keeps the mic open is not a pause anyone trusts.
A reading in progress stops, and the engine learns how far Dad heard, exactly as if he had cut in.
The rest of a reply still on its way is passed over for good. Its words stay in the thread; his next turn clears the pass-over.
Nothing listens until Resume.
An utterance already finished and uploading still goes out. Pause never eats something he already said.
That last promise has one hole, and it is still open as this quest is written. Every listen stamps a fresh generation number so a late result from an older listen can't land in a newer one. Resume starts a listen. So if Dad resumes before the paused upload finishes, the upload comes back carrying the old generation and is discarded as stale: the audio is kept, but the turn never goes out. Pause alone is safe; pause-then-quick-resume is the race. A generation guard protects against old results, and an old result is exactly what a paused upload is.
Resume listens at once. And the face tap kept a clear meaning per phase: pause while listening or thinking, resume while paused or stopped, cut in while she speaks.
A Key, Configurable, Out of the Way
The same day came the next ask: a shortcut, configurable, Space by default on the web and on Firekeeper. On the web it is a house setting in Admin: a keyboard code, bare or after modifiers, or disabled, normalized on the server so a messy value can't reach the page. The hard part is not matching the key. It is never matching it when it means something else:
It never fires while Dad types in a field.
It never fires while an input method is composing, which matters enormously in Korean: a Space in the middle of assembling a Hangul syllable belongs to the IME.
Its keyup is swallowed along with it, so a focused button can't read the same key as a click and toggle pause a second time; a held key's auto-repeat is one press, not a drumroll.
The face screen takes keyboard focus from any field it covers, because a composer that held focus before hands-free would otherwise take Space as a typed space.
Admin records a new key with a Change button, plus Off and Default. A modifier key on its own can't be recorded on the web, because the browser is never told that Command was released after Command-Tab; Firekeeper, as a native Mac app, can and does use one: a lone tap of the left Command key cuts in there, while Space pauses.
Verified Like a User
In the browser pane, Pause turned the fake microphone's track to ended and showed "Paused. The microphone is off." with Resume. Resume opened a new listening session on a live track. That is the check that matters: not that a flag flipped, but that the microphone really let go.
Code
A pause key that never fights typing, IME or focus·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'));
Run the code with npx tsx and read the output. Then add two cases: the key arrives with keyCode 229 but isComposing false (some browsers do this), and the setting is 'disabled'. Finally, write the pause function itself for a web voice screen: stop every track of the microphone stream, stop playback while reporting how far the listener heard, and mark the rest of the reply as passed over.
Hint
The microphone is released by calling stop() on each track of the stream you got from getUserMedia, and the proof is the track's readyState becoming 'ended'. Report the heard position before you stop playback, not after: once the audio element is reset its currentTime is gone.
Progress
Progress is local-only — sign in to sync across devices.