Skip to content
C.W.K.
Lesson 01 of 09 · published

The Loop, and Who Owns Each State

~14 min · state-machine, hands-free, audio-session, ios

Level 0Muted
0 XP0/64 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"Okay. Let's go to step 2." — Dad, starting hands-free, 2026-09-25

The Loop on Paper

On paper, a hands-free talk is a circle: idle, listening, sending, thinking, speaking, and back to listening. The design drew exactly that. The implementation taught something the drawing hides: those states don't all belong to the same component. Listening belongs to the microphone loop. Thinking belongs to the chat stream. Speaking belongs to the audio player. A loop that tries to own all of them ends up guessing about the two it doesn't control.

The Listener's Own Phases

So the hands-free loop, on the web and on the phone alike, owns only its own phases: off, connecting (asking the brain for a listening session and opening the socket), listening (streaming 16 kHz audio and showing partial words), waiting (an utterance has ended; it is being kept, sent, and answered), paused and stopped. Thinking and speaking it simply observes. It starts listening again only when two independent facts are both true: the reply's stream has ended, and the player reports that nothing is playing or queued. That second fact needed its own fix on the web, where the player hadn't been releasing finished audio elements, so "is anything playing?" could still answer yes after the last word had played.

The Microphone Stays Open; the Socket Doesn't Always Listen

The browser opens the microphone once per talk, with echo cancellation on, and keeps it. Audio chunks go to the transcriber only while the loop is listening; while the soul thinks or speaks they go nowhere except to the barge-in detector of Track 7. Before barge-in existed, the rule was simpler still: listening ended at the commit and reopened after the reading, so the soul could never hear herself.

A Quiet Room Is Not a Talk

An open realtime socket bills for the audio it hears, silence included. After 60 seconds with nothing heard, the loop stops itself and says so on the face screen. One tap listens again.

The Phone Needs One Owner for Audio

On iOS the loop taught one more lesson. At first the player and the recorder each configured the audio session on their own. Worse, iOS suspends an app whose audio session goes idle, and in a talk the idle part was the soul's thinking. Now a single owner sets the session to play-and-record once and keeps the microphone open from the first listen until hands-free ends, and the player leaves the session alone while that owner holds it. The talk continues with the screen off and the phone in a pocket, and each turn gets a Live Activity that stays on the lock screen until the next turn replaces it, showing "listening…" or "speaking…".

Code

The listener's phases as a pure reducer·typescript
// The hands-free loop as a pure reducer: every transition is explicit and testable.
type Phase = 'off' | 'connecting' | 'listening' | 'waiting' | 'paused' | 'stopped';

type Event =
  | { kind: 'start' }
  | { kind: 'socketOpen' }
  | { kind: 'utterance'; text: string }        // end of speech decided; its upload begins
  | { kind: 'uploaded' }                        // the finished utterance reached the server
  | { kind: 'replyDone'; playerBusy: boolean }  // the reply's stream ended
  | { kind: 'playerIdle' }                      // nothing playing or queued
  | { kind: 'quietMinute' }
  | { kind: 'socketClosed'; code: number; reason: string }
  | { kind: 'pause' }
  | { kind: 'resume' }
  | { kind: 'close' };

interface Loop { phase: Phase; reason: string | null; streamEnded: boolean; delivering: boolean }

const OFF: Loop = { phase: 'off', reason: null, streamEnded: false, delivering: false };
const LISTEN_AGAIN: Loop = { phase: 'connecting', reason: null, streamEnded: false, delivering: false };

export function step(loop: Loop, event: Event): Loop {
  switch (event.kind) {
    case 'start':
      return loop.phase === 'off' ? LISTEN_AGAIN : loop;
    case 'resume':
      if (loop.phase !== 'paused' && loop.phase !== 'stopped') return loop;
      // Words he finished before the pause are still on their way: wait for
      // them and her reply, as if he had never paused.
      return loop.phase === 'paused' && loop.delivering ? { ...loop, phase: 'waiting' } : LISTEN_AGAIN;
    case 'socketOpen':
      return loop.phase === 'connecting' ? { ...loop, phase: 'listening' } : loop;
    case 'utterance':
      return loop.phase === 'listening'
        ? { ...loop, phase: 'waiting', streamEnded: false, delivering: true } : loop;
    case 'uploaded':
      return { ...loop, delivering: false };
    case 'replyDone':
      if (loop.phase !== 'waiting') return loop;
      // Listen again only when the reply is finished AND nothing is playing.
      return event.playerBusy ? { ...loop, streamEnded: true } : LISTEN_AGAIN;
    case 'playerIdle':
      // An idle player before the reply has ended is not a reason to listen.
      return loop.phase === 'waiting' && loop.streamEnded ? LISTEN_AGAIN : loop;
    case 'quietMinute':
      return loop.phase === 'listening'
        ? { ...loop, phase: 'stopped', reason: 'Nothing heard for a minute; tap to listen again.' } : loop;
    case 'socketClosed':
      // A close he caused (the close button, a pause) is not news; any other is.
      if (loop.phase === 'off' || loop.phase === 'paused' || loop.phase === 'stopped') return loop;
      return { ...loop, phase: 'stopped', reason: `Listening closed (${event.code}): ${event.reason || 'no reason given'}` };
    case 'pause':
      return loop.phase === 'off' || loop.phase === 'stopped' ? loop : { ...loop, phase: 'paused' };
    case 'close':
      return OFF;
  }
}

function run(title: string, events: Event[]) {
  let loop = OFF;
  console.log(`-- ${title}`);
  for (const event of events) {
    loop = step(loop, event);
    console.log('  ' + event.kind.padEnd(12), '->', loop.phase, loop.reason ?? '');
  }
}

run('a turn, then a quiet minute', [
  { kind: 'start' }, { kind: 'socketOpen' }, { kind: 'utterance', text: '내일 날씨 어때?' },
  { kind: 'uploaded' },
  { kind: 'playerIdle' },                        // too early: the reply hasn't ended
  { kind: 'replyDone', playerBusy: true }, { kind: 'playerIdle' },
  { kind: 'socketOpen' }, { kind: 'quietMinute' },
]);
run('paused while the words were still uploading', [
  { kind: 'start' }, { kind: 'socketOpen' }, { kind: 'utterance', text: '잠깐만' },
  { kind: 'pause' }, { kind: 'resume' },          // back to waiting, not listening
  { kind: 'uploaded' }, { kind: 'replyDone', playerBusy: false },
]);
run('a close he caused, then the socket reports it', [
  { kind: 'start' }, { kind: 'socketOpen' }, { kind: 'close' },
  { kind: 'socketClosed', code: 1000, reason: 'closed by client' },   // stays off
]);

External links

Exercise

Run the reducer (npx tsx loop.ts) and read its three runs. Then add two events you think are missing: the microphone refused before the socket ever opens, and the user sending a typed message while the loop is waiting. Decide the next phase for each and add a run that exercises it.
Hint
A refused microphone is a stop with its reason on screen, like any other failure: the face stays up and says why, and a tap listens again. A typed message while waiting doesn't change the listener at all: the reply to it will end and the player will go idle, and the loop listens again from those facts.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.