ZB Field Notes

The mic was the easy part

The mic was the easy part

My CV site has an AI concierge on it — a small chat panel where a signed-in recruiter can ask questions about my background and get answers grounded in a dossier and my recent posts. I have just added a Dictate button to it, so you can speak the question instead of typing it.

The interesting part is not the microphone. The microphone took about twenty minutes. Everything that came after it took the rest of the afternoon, and that is the part worth writing down.

The five-minute version genuinely works

Browsers ship a speech recogniser. No backend, no API key, no cost, no dependency. This really is the whole thing:

const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
const recognition = new SpeechRecognition()
recognition.continuous = true
recognition.interimResults = true

recognition.onresult = (event) => {
  let transcript = ''
  for (let i = event.resultIndex; i < event.results.length; i++) {
    transcript += event.results[i][0].transcript
  }
  console.log(transcript)
}

recognition.start()

Paste that into a console and it works. Ship it into a real composer and it is subtly wrong in three ways.

Trap 1: the results list is cumulative

This is the one that bites. event.results is not "what changed" — it is every result for the whole recognition session, and each result flips from interim to final exactly once. So the loop above re-emits everything that already settled, on every tick. Dictate one sentence and it lands in your input two or three times.

The fix is a watermark: remember how far you have committed and never look below the line again.

export function readResults(results: SpokenResults, from: number) {
  let final = ''
  let interim = ''
  let next = from
  // Once we hit something unsettled, everything after it is a tail — even a later
  // result that claims to be final.
  let settled = true

  for (let i = from; i < results.length; i += 1) {
    const result = results.item(i)
    const text = result.item(0).transcript
    if (settled && result.isFinal) {
      final += text
      next = i + 1
    } else {
      settled = false
      interim += text
    }
  }

  return { final, interim, next }
}

That settled flag is doing more than it looks. The watermark may only advance across a contiguous run of finals. Jump a gap and you silently drop the unsettled result sitting in it; commit across one and you replay it the moment it settles. Holding the line costs a slightly longer interim tail and loses nothing.

Traps 2 and 3: the boring ones

Chunks arrive with no leading whitespace, so a straight existing + chunk welds words together — "…with Kafka" plus "and Spring" gives you "Kafkaand Spring".

And maxLength on a <textarea> only governs keystrokes. A programmatic setState sails straight past it, so a long dictation quietly exceeds the 500-character bound my server enforces and the question bounces off a 400 at send time. Both are two lines:

export function appendSpoken(existing: string, chunk: string, maxLength: number): string {
  const spoken = chunk.trim()
  if (!spoken) return existing
  const joined = existing && !/\s$/.test(existing)
    ? `${existing} ${spoken}`
    : `${existing}${spoken}`
  return joined.length > maxLength ? joined.slice(0, maxLength) : joined
}

Both of those are pure functions, which is the point — they are the only parts of this feature I could unit test without a DOM or a microphone.

The decision that actually mattered

The obvious implementation writes the live transcript straight into the textarea, so you watch your words appear as you speak. I built that first. It is wrong.

Interim results are rewritten many times a second. Piping them into a controlled input means the value is being overwritten continuously, which fights your cursor the instant you touch the keyboard to fix something. It also needs a "what was in the box before the mic started" baseline, and that baseline goes stale on every manual edit.

So: interim text never enters the input. It renders in a separate muted line underneath, and the textarea only ever grows, by whole settled phrases. Typing and dictating stop competing and start composing. The ghost line is aria-hidden — announcing every revision would bury the words that actually landed — while aria-pressed on the button carries the mic state.

The concierge panel with the microphone live: the Dictate button has turned into a red Stop pill, the textarea still shows its example placeholder, and a muted line beneath it reads “Listening… your browser is transcribing.”
Mid-turn, before anything has settled. The button is lit, the composer is still untouched, and the live state lives on its own line underneath.

Do not auto-send

It is tempting to fire the question automatically when someone stops talking. Don't.

Browser transcription is good, not perfect. Testing it live, I said "does Zakaria know about Spring Batch" and got back "does the Zakaria know about Spring batch". Perfectly understandable, slightly wrong, and completely fine — because it lands in a text box where I can see it and fix it before sending.

The same panel a moment later: the settled phrase “does the Zakaria know about Spring batch” now sits in the composer with the browser’s spell-check underlining it, while the microphone is still running.
The settled phrase, spell-check squiggles and all. A stray “the” is exactly why this waits for a human rather than sending itself.

Auto-send would have turned that into a spent question from a fixed quota, answered from a subtly mangled prompt. The review step is not friction in the way of the feature. It is the feature.

The hot mic

Here is the failure mode that actually worried me: a microphone that keeps listening after the moment it belongs to has passed.

Two things in my UI can make the composer go away without unmounting the component that owns the recogniser. Closing the chat panel is one. The other I only found because I went looking: an easter egg overlay on the site sits at z-index: 80, above the panel's 70. Trigger it mid-dictation and the mic keeps running with its lit "Stop" button hidden behind a full-screen overlay. Rare. Also exactly the kind of thing you do not want to explain afterwards.

So the recogniser hangs off a single enabled flag that folds in every condition, and there is one more ordering detail that matters:

function detach(recogniser: Recogniser | null): void {
  if (!recogniser) return
  // Unhook FIRST, then abort. abort() still queues an `end` event, so a recogniser
  // retired while a new turn is starting would otherwise flip `listening` back to
  // false a tick after the new one set it true.
  recogniser.onresult = null
  recogniser.onerror = null
  recogniser.onend = null
  recogniser.abort()
}

I also stopped reusing one recogniser across turns and started building a fresh one each time. That single change deleted three problems at once: the stale lang, the watermark reset, and the InvalidStateError window while a previous session is still unwinding. Constructing one costs nothing.

The part that wasn't code

Here is what the API docs bury: in Chrome, Edge and Safari, "the browser transcribes it" means the browser ships your audio to the vendor's speech service. It is not local. It is a network call to Google or Apple with a recording of someone's voice on it.

My privacy page said, in writing, that there are no third-party trackers on the site and listed exactly where every byte goes. Adding this feature made a third party a participant for the first time. That is not a UI change; it is a disclosure event.

So the same commit that added the button added a Voice input section to the privacy page, stating plainly that the audio goes to the browser vendor, that the site itself never receives or stores it, that only the text you choose to send arrives here, and that typing avoids the whole thing. That instinct comes straight from my day job in regulated banking, where "we added a small feature and data started leaving the building" is a sentence with consequences.

One related call: where the API is missing — Firefox, most Android WebViews — the button is not rendered at all, rather than rendered and disabled. A greyed-out control that can never work only advertises an absence.

A footnote for the TypeScript people

TypeScript's DOM library ships SpeechRecognitionEvent, SpeechRecognitionErrorEvent and SpeechRecognitionErrorCode — but not SpeechRecognition itself, and nothing on Window. The constructor is still vendor-prefixed, so there is no standard name for the lib to declare. You get the payload types for free and hand-roll the eight members of the recogniser you actually touch.

Worth it?

Yes, and not because dictation is impressive — it is a browser primitive that has been sitting there for years. It is worth it because the feature is a good miniature of how this work usually goes. The capability was free and took twenty minutes. Making it behave correctly in a real interface, refuse to leave a microphone running, and tell the truth about where the audio goes took the rest of the day.

The mic was the easy part.