Skip to content
Basil Francis Alajid
typescriptreactwebdevportfolio

Why My Portfolio Spoke German

Basil Francis Alajid
July 20, 2026
6 min read (1,364 words)β€”

This site's chat assistant has a speak-aloud button. Click it, and window.speechSynthesis reads the reply out loud. I caught it broken myself, poking at my own assistant. Not broken as in silent -- broken as in it answered a question about my TypeScript experience in a German accent. Same English text, same words, read by whatever voice Chrome decided sounded right.

That's not a translation bug. That's a .find() returning the wrong element and nobody noticing.

Problem

The original voice picker looked like this:

ts
const preferred = voices.find(v =>
  v.name.includes("Google") || v.name.includes("Natural") || v.name.includes("Samantha")
) || voices.find(v => v.lang.startsWith("en")) || voices[0];

Array.find() returns the first match in whatever order speechSynthesis.getVoices() reports, and that order is not alphabetical by language -- it's whatever the underlying engine feels like. On desktop Chrome, "Google Deutsch" sorts ahead of "Google US English" in that list. The code was matching on the string "Google" with no language check at all, so it happily handed English text to a German voice. It never crashed, never logged an error, never showed up in any test -- it just quietly spoke the wrong language every time that voice ordering came up.

The pickEnglishVoice regression test I wrote for the fix says it plainly: "prefers an English voice over Google Deutsch even when Deutsch comes first." That sentence is the entire bug report.

Constraints

Three things bounded the fix:

  1. Hook API unchanged. useSpeechSynthesis() still returns { speak, stop, isSpeaking, isSupported }. The chat panel that calls it didn't need to change.
  2. Testable without a browser. Voice selection, text cleanup, and chunking had to be pure functions I could run in Vitest under Node -- no window, no real SpeechSynthesisVoice, no spinning up a headless Chrome just to check that "Google Deutsch" loses to "Google US English."
  3. Every other Chrome quirk found along the way gets fixed in the same pass, not filed as a follow-up. Once I went looking at how speechSynthesis actually behaves instead of how the API docs describe it, four more silent failures turned up. Shipping the voice fix alone and leaving those would have traded one invisible bug for another.

System

The fix splits into two files. src/lib/speech.ts holds pure helpers -- cleanSpeechText, chunkSpeechText, pickEnglishVoice -- with zero DOM access, so they run in plain Node under Vitest. src/hooks/useSpeechSynthesis.ts is the thin layer that wires those helpers to the real window.speechSynthesis, holding the mutable state a browser API demands: a cached voice list, strong references to in-flight utterances, a monotonic session counter, and a pending-timer handle.

That split is what made the rest of this debuggable at all. Every one of the bugs below is Chrome-specific browser behavior, but the logic that has to get it right -- which voice wins, where a chunk boundary falls -- lives in code that never touches a browser.

Execution

Bug 1: name matching with no language filter

pickEnglishVoice now filters to English voices before it scores anything. Non-English voices are never candidates, full stop:

ts
export function pickEnglishVoice<T extends VoiceLike>(
  voices: readonly T[]
): T | undefined {
  const english = voices.filter((v) => normalizeLang(v.lang).startsWith("en"));
  if (english.length === 0) return undefined;

  let best = english[0];
  let bestScore = scoreVoice(best);
  for (let i = 1; i < english.length; i++) {
    const score = scoreVoice(english[i]);
    if (score > bestScore) {
      best = english[i];
      bestScore = score;
    }
  }
  return best;
}

Scoring only runs inside that filtered set:

ts
const QUALITY_VOICE_NAME = /google|natural|samantha/i;

function scoreVoice(voice: VoiceLike): number {
  let score = 0;
  if (QUALITY_VOICE_NAME.test(voice.name)) score += 4; // natural-sounding
  if (normalizeLang(voice.lang).startsWith("en-us")) score += 2; // assistant writes US English
  if (voice.localService) score += 1; // immune to the network-voice cutoff
  return score;
}

normalizeLang lowercases and swaps underscores for hyphens, because Android reports en_US instead of en-US -- a real format the old code would have also silently mishandled. And on top of the picker, every utterance now gets utterance.lang = "en-US" set explicitly, so even a missing voice falls back to the right locale instead of the OS default.

Bug 2: the voiceschanged race

getVoices() returns an empty array on Chrome until the browser finishes loading its voice list asynchronously, which fires a voiceschanged event whenever it's ready. If you call speak() before that event fires, you get whatever default the browser falls back to. The fix caches the list and refreshes it on the event:

ts
const refreshVoices = () => {
  const list = synth.getVoices();
  if (list.length > 0) voicesRef.current = list;
};
refreshVoices();
// addEventListener (not onvoiceschanged assignment) -- this hook mounts
// once per chat message, and property assignment would clobber siblings.
if (typeof synth.addEventListener === "function") {
  synth.addEventListener("voiceschanged", refreshVoices);
}

That comment about property assignment matters. useSpeechSynthesis mounts once per chat message, not once per page, because every message with a speak-aloud button gets its own hook instance. Setting synth.onvoiceschanged = refreshVoices from every instance means the last one to mount wins and every earlier instance's listener is gone. addEventListener lets them coexist.

Bug 3: the ~15-second cutoff

Chrome kills network-voice utterances (the good-sounding "Google" ones) roughly 15 seconds into a single long utterance -- speech just stops, and onend never fires, so isSpeaking stays stuck true forever. There's no error, no event, nothing to catch. The fix is to never hand Chrome one long utterance: text gets chunked into sentence-sized pieces and queued back-to-back.

ts
export const MAX_SPEECH_CHUNK_LENGTH = 180;

// A sentence: everything up to punctuation followed by whitespace/end (so
// decimals like "3.5" don't split), or the unpunctuated remainder of a line.
const SENTENCE_RE = /.*?[.!?]+(?=\s|$)|.+$/g;

The (?=\s|$) lookahead is doing the real work: a period only ends a sentence if it's followed by whitespace or the end of the string. "It runs 3.5x faster." doesn't split after the "3." because there's no whitespace there -- it splits after "faster." where there is. A sentence longer than 180 characters gets broken again at word boundaries, never mid-word.

Bug 4: the enqueue-after-cancel drop

Calling speechSynthesis.cancel() and then immediately calling speak() in the same tick sometimes silently drops the new utterance in Chrome -- no error, it just never plays. The fix defers the actual enqueue by one tick:

ts
pendingTimerRef.current = window.setTimeout(() => {
  pendingTimerRef.current = null;
  if (session !== sessionRef.current) return;
  // ...pick voice, build utterances, synth.speak() each one
}, 60);

60ms, chosen because it's comfortably past a cancel() settling and short enough that nobody perceives it as a delay before speech starts. It also has a side benefit: it gives a voiceschanged event that fires around the same moment a chance to land before voice selection runs.

Bug 5: Chrome garbage-collects your utterances

If a SpeechSynthesisUtterance object has no other JavaScript reference holding onto it, Chrome can garbage-collect it while it's still queued -- and when that happens, its onend/onerror callbacks silently never fire. Chunking (bug 3) makes this worse, not better: now there are several utterances queued at once, and it's easy to only keep the current one in scope. The fix is a ref array holding every utterance for the life of the queue:

ts
const utterancesRef = useRef<SpeechSynthesisUtterance[]>([]);
// ...
utterancesRef.current = utterances;
for (const utterance of utterances) synth.speak(utterance);

Nothing fancy -- just making sure the objects stay reachable until the browser is done with them.

Bug 6: stale callbacks from a cancelled read

Click speak-aloud on one message, then immediately click it on another. The first message's utterances are still in flight when they get cancelled, and their callbacks can still fire afterward. Without protection, a stale onend from message A can flip isSpeaking to false right after message B started speaking. The fix is a monotonic session token bumped on every cancel:

ts
const cancelCurrent = useCallback(() => {
  sessionRef.current += 1;
  // ...clear timers, clear utterance refs, synth.cancel()
}, []);

Every callback checks its captured session against sessionRef.current before touching state. A same-day post-review pass added one more layer: onerror now also clears isSpeaking for a stale session if nothing newer is queued or pending -- otherwise a stale error could still be the last event the hook ever receives, and the UI would stay stuck on "speaking" with no way to un-stick it.

Outcome

The voice picker, the cleanup pass, and the chunker are covered by 27 unit tests on the pure helpers in speech.ts -- including the one that pins the actual bug: an English voice must win over "Google Deutsch" even when Deutsch comes first in the list. useSpeechSynthesis's public surface didn't move, so the chat panel that calls it required zero changes.

One open item I'm flagging rather than pretending is solved: chunked playback has a barely audible pause between sentences, which is the trade-off for dodging the 15-second cutoff. The chunk boundaries are pinned down in tests, but the actual listening experience hasn't had a real-device pass across the Chrome builds this could ship to.

Lessons

A silent failure is worse than a crash. Every single one of these six bugs -- wrong language, wrong voice, stuck isSpeaking, dropped utterance, dropped events, stale callback -- fails without throwing, logging, or rejecting anything. That's exactly why the German-voice bug shipped and sat there unnoticed: there was nothing for a stack trace to catch.

Keep browser logic thin and pure logic fat. Splitting speech.ts (no DOM) from useSpeechSynthesis.ts (all DOM) meant the actual bug -- voice selection -- lives in code I can unit test in milliseconds under Node. The parts that have to touch window.speechSynthesis are reduced to plumbing.

.find() on browser-supplied data is a bet on ordering you don't control. The original bug wasn't really "wrong regex" -- it was trusting that Chrome's voice list would put an English voice first. It doesn't, and there was no reason to assume it would.

When you go looking for one bug, budget for finding five. I went in to fix a language-picking bug and came out having also fixed an async race, a hard cutoff, a drop-on-cancel quirk, a garbage-collection trap, and a stale-callback leak. None of those were in the original report. All of them were already live in production.

0
0
0
0
0

Share

Enjoyed this post?

Follow along for more engineering deep dives and project breakdowns.

No spam, ever.

|RSS

Comments

Comments coming soon.

You found the bottom! 🎯