Skip to content
C.W.K.
Stream
Lesson 06 of 07 · published

Send Now

~13 min · end-of-speech, manual-commit, timeouts, mobile-network

Level 0Muted
0 XP0/57 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"Outside there's so much noise that I end up waiting a really long time... voice mode needs something like a Send Now button." — Dad, 2026-10-09

When Silence Never Comes

Everything this track built so far ends a turn the same way: Dad stops, the room goes quiet, Scribe commits after its silence threshold, and the loop waits out the rest of his window. That is a fine rule at a desk. On a street it never fires. Traffic, wind and a passing bus never read as silence, so Scribe never commits and the three seconds never even start. Dad would finish a sentence and then stand there, waiting for a pause the street was never going to give him.

The tempting fixes all guess. Lower the threshold and every pause for breath ends a turn indoors. Make the threshold follow the room's noise and you have a model of the street to tune and to doubt. Dad asked for the simple thing instead: a button that says I'm done. A window that adapts to noise is still not built. The button settled the street first.

A Manual Commit

Scribe's realtime socket already has the move. A client may send an audio message with no audio in it and commit: true, and a session running the vad strategy honours it: Scribe closes the segment right there and answers with its words. Measured on a probe, the answer took 0.23 seconds. So Send now asks Scribe to commit what it has, and the turn goes out the moment those words land. The window wait and the forced-commit check from this track's third and fourth lessons are skipped on purpose: the press is Dad's end of speech, whatever the room's loudness says.

Three edges are handled by name. A press while the loop is already waiting out the rest of his window after a silence commit has nothing more to ask for, so the turn goes at once. A commit that comes back empty over a turn with nothing heard keeps listening; the phone says so on the screen (Nothing heard yet to send. Pippa is still listening.). And a turn whose words come out empty never goes out at all; on the phone the talk says why and listens again.

Never Wait on the Ear

The first version waited for the commit's words, and a day later it broke. On a weak mobile link the socket's audio queued up on the way out, and Scribe's words trailed Dad by many seconds. A press then found nothing heard, waited for a commit that was itself stuck in the same queue, dropped itself, and changed nothing on the screen. Dad's report was blunt: a regression, and the button was dead. His kept recording of that turn showed how bad it was: his speech ended at 3.9 seconds and the turn went out at 21.6, a 17.7-second tail against about 3.5 on every other dictated turn that day.

The rule since 2026-10-10: the ear is never waited on past 1.5 seconds. The phone's button reads Sending… from the moment it is pressed, so a press never looks like nothing. If the commit doesn't answer in time, the turn ends anyway. The phone already holds the whole recording, so it transcribes that through the batch path, with the live words as the fallback if that fails too. The web sends what it heard live, the committed pieces plus the last partial, and keeps listening when it heard nothing at all. An utterance of 30 seconds or more is still transcribed whole there, as it always is. Either way the slow part can make the words late; it can no longer make the press disappear. Dad confirmed it on his own phone before the fix was called done.

Every Door Has the Button

  • The WebUI shows a wide teal Send now under the face while she listens, and Enter does the same. Enter is taken only while listening, never with a modifier, never while Dad is typing in a field or composing Hangul; everywhere else Enter is left alone.
  • The phone has the same button under the face (the screenshot below). The headphones' play/pause press is mapped too: while she listens it is Send now, while she speaks it cuts in. iOS gives a recording app no AirPods gesture of its own, so whether the press reaches a talk that is recording and playing nothing is for Dad's AirPods to say. The screen's button doesn't depend on it.
  • The watch sends on a tap of its listening ring, without waiting for silence. Track 6 tells how the watch came to take one turn per press.

Code

The web's Send now: one manual commit, and a deadline that never waits past 1.5 s·typescript
  // frontend/src/hooks/useHandsFree.ts (cwkPippa), verbatim
  const sendNow = useCallback((): boolean => {
    if (phaseRef.current !== 'listening' || !captureRef.current) return false;
    if (sendNowRef.current !== null) return true;
    const socket = socketRef.current;
    if (windowWaitRef.current !== null && segmentsRef.current.length && !partialRef.current) {
      // Scribe already committed after silence and the loop is waiting out the
      // rest of Dad's window: nothing more to ask for.
      void finishTurn();
      return true;
    }
    sendNowRef.current = window.setTimeout(() => {
      sendNowRef.current = null;
      if (phaseRef.current !== 'listening') return;
      // The commit's words never came: what was heard live goes out.
      const partial = partialRef.current.trim();
      if (partial) segmentsRef.current.push(partial);
      if (segmentsRef.current.length) void finishTurn();
    }, SEND_NOW_WAIT_MS);
    if (socket && socket.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify({ message_type: 'input_audio_chunk', audio_base_64: '', commit: true, sample_rate: SAMPLE_RATE }));
    }
    return true;
  }, [finishTurn]);
A minimal sketch: the press, the commit, and what happens when the ear is late·python
import asyncio

SEND_NOW_WAIT = 1.5   # seconds: never wait on the ear longer than this


async def send_now(scribe_commit, heard_live: list[str], partial: str, *, phone: bool) -> str:
    """Dad pressed Send now: ask for a manual commit, never wait on it for long."""
    try:
        words = await asyncio.wait_for(scribe_commit(), SEND_NOW_WAIT)
    except asyncio.TimeoutError:
        # The commit is late, so the live words are late too.
        if phone:
            return "turn ends; the recording on the phone is transcribed whole"
        live = heard_live + ([partial] if partial else [])
        if not live:
            return "keep listening: nothing heard yet to send"
        return f"turn goes out with what was heard live: {' '.join(live)!r}"
    pieces = heard_live + ([words] if words else [])
    if not pieces:
        return "keep listening: nothing heard yet to send"
    return f"turn goes out: {' '.join(pieces)!r}"


def scribe(after: float, words: str):
    async def commit() -> str:
        await asyncio.sleep(after)   # the socket's queue, then Scribe's answer
        return words
    return commit


async def main() -> None:
    # A quick link: the manual commit answers in about a quarter-second.
    print(await send_now(scribe(0.23, "and that's the plan"), ["Okay,"], "and that's", phone=False))
    # A weak mobile link: the socket's audio is queued and the answer is late.
    print(await send_now(scribe(6.0, "and that's the plan"), ["Okay,"], "and that's", phone=False))
    print(await send_now(scribe(6.0, "and that's the plan"), ["Okay,"], "and that's", phone=True))
    # A press before a single word: Scribe commits nothing over nothing.
    print(await send_now(scribe(0.2, ""), [], "", phone=True))
    # The same empty press on the web, with the commit stuck behind the queue.
    print(await send_now(scribe(3.0, ""), [], "", phone=False))


asyncio.run(main())

# Output:
# turn goes out: "Okay, and that's the plan"
# turn goes out with what was heard live: "Okay, and that's"
# turn ends; the recording on the phone is transcribed whole
# keep listening: nothing heard yet to send
# keep listening: nothing heard yet to send

External links

Exercise

Run the sketch, then change the weak-link delay from 6.0 to 1.0 seconds and predict each line before you rerun it. Then take a voice loop of your own (or the sketch) and add the missing door: a key that ends the turn. Decide when the key must be ignored, and write one test for each case.
Hint
At 1.0 seconds the commit beats the 1.5-second deadline, so both weak-link lines turn into ordinary sends with the full sentence. For the key, the WebUI's rules are a good checklist: only while listening, never with a modifier held, never while the focus is in a text field, and never in the middle of an IME composition, because Enter there finishes a Hangul syllable rather than a turn. A key repeat should press once, not five times.

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.