feat(coach): select a natural speech voice for read-aloud

Read-aloud only set the utterance language and let the browser fall back
to its default voice, which on most systems is a legacy compact engine
that reads robotically. Enumerate the installed voices, rank them for the
active locale (prefer enhanced/premium, Siri, Microsoft Natural/Online,
Google, and an exact lang-region match; penalise known compact engines),
and assign the winner with a natural rate and pitch. The voice list loads
asynchronously, so subscribe to voiceschanged and re-rank lazily; when no
voice beats the legacy default, fall back gracefully to prior behaviour.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 18:05:09 +02:00
1 parent bd1a5e2da5
commit 7f2ed0c223
2 files changed
+182 -4

No files matched your search

@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { pickSpeechVoice, scoreSpeechVoice } from "../message-thread";
type V = { name: string; lang: string; localService?: boolean };
const asVoices = (vs: V[]) => vs as unknown as SpeechSynthesisVoice[];
describe("scoreSpeechVoice", () => {
it("rejects voices that do not match the target language", () => {
expect(scoreSpeechVoice({ name: "Samantha", lang: "en-US" }, "de-DE")).toBe(
-Infinity,
);
});
it("ranks an enhanced/premium voice above a legacy compact one", () => {
const enhanced = scoreSpeechVoice(
{ name: "Samantha (Enhanced)", lang: "en-US" },
"en-US",
);
const compact = scoreSpeechVoice(
{ name: "Albert", lang: "en-US" },
"en-US",
);
expect(enhanced).toBeGreaterThan(compact);
expect(compact).toBeLessThan(0);
});
it("rewards an exact lang-region match over a bare language match", () => {
const region = scoreSpeechVoice({ name: "Google", lang: "de-DE" }, "de-DE");
const bare = scoreSpeechVoice({ name: "Google", lang: "de-AT" }, "de-DE");
expect(region).toBeGreaterThan(bare);
});
});
describe("pickSpeechVoice", () => {
it("returns the best-scoring voice for the locale", () => {
const chosen = pickSpeechVoice(
asVoices([
{ name: "Anna", lang: "de-DE", localService: true },
{ name: "Google Deutsch", lang: "de-DE" },
{ name: "Samantha", lang: "en-US" },
]),
"de-DE",
);
expect(chosen?.name).toBe("Google Deutsch");
});
it("returns null when nothing beats a legacy default", () => {
const chosen = pickSpeechVoice(
asVoices([{ name: "Albert", lang: "en-US" }]),
"en-US",
);
expect(chosen).toBeNull();
});
it("returns null on an empty voice list (graceful fallback)", () => {
expect(pickSpeechVoice(asVoices([]), "en-US")).toBeNull();
});
});
@@ -383,12 +383,87 @@ function useSpeechSynthesisSupported(): boolean {
);
}
/**
* v1.22 — score a synthesis voice for a target language. Higher is better;
* a negative score marks a legacy/compact voice we would rather skip. Pure
* (no browser globals) so the ranking contract is unit-testable. Exported
* for the read-aloud voice-selection test.
*
* The default OS voice is often a legacy "compact" engine (Anna, Albert,
* Zarvox, eSpeak) that reads robotically. We prefer, in order: an Apple
* enhanced/premium or Siri voice, a Microsoft "Natural"/"Online" (Edge)
* voice, a "Google …" (Chrome) voice, and a small set of known-good names
* (Samantha). An exact lang-REGION match (de-DE/en-US) outweighs a bare
* language match. `localService` only breaks a tie. Known-bad names are
* penalised so they never win over a neutral remote voice.
*/
export function scoreSpeechVoice(
voice: { name: string; lang: string; localService?: boolean },
lang: string,
): number {
const base = lang.split("-")[0].toLowerCase();
const vlang = voice.lang.toLowerCase().replace("_", "-");
if (!vlang.startsWith(base)) return -Infinity;
const name = voice.name.toLowerCase();
let score = 0;
// Exact lang-REGION match (de-DE, en-US) over a bare language match.
if (lang.includes("-") && vlang === lang.toLowerCase()) score += 40;
// High-quality engines.
if (/\b(enhanced|premium)\b/.test(name)) score += 60;
if (name.includes("siri")) score += 55;
if (name.includes("natural") || name.includes("online")) score += 50;
if (name.startsWith("google")) score += 35;
if (name.includes("samantha")) score += 30;
// Known-bad legacy / compact engines.
if (name.includes("compact")) score -= 80;
if (/\b(albert|zarvox|espeak|anna|fred|ralph|bahh|trinoids)\b/.test(name)) {
score -= 100;
}
// Tie-breaker only: a local voice has no network latency.
if (voice.localService) score += 1;
return score;
}
/**
* v1.22 — pick the best-scoring installed voice for `lang`, or null when
* none beats a legacy/compact default (graceful fallback to the browser
* default). Pure helper, exported for the ranking test.
*/
export function pickSpeechVoice(
voices: SpeechSynthesisVoice[],
lang: string,
): SpeechSynthesisVoice | null {
let best: SpeechSynthesisVoice | null = null;
let bestScore = 0; // require a positive score to override the default
for (const v of voices) {
const s = scoreSpeechVoice(v, lang);
if (s > bestScore) {
bestScore = s;
best = v;
}
}
return best;
}
/**
* v1.22 (W5) — read the assistant prose aloud via the browser's
* `SpeechSynthesis`. Client-only, no dependency, no egress, CSP-clean. A
* single in-flight utterance: a new `speak` cancels any prior one; `toggle`
* stops while speaking. The utterance language follows the active UI locale
* so the browser picks the closest installed voice (best-effort by OS).
* stops while speaking.
*
* v1.22 — natural-voice selection. The browser default is frequently a
* legacy/compact engine that reads robotically; instead of only stamping
* `utterance.lang` we enumerate `getVoices()`, rank the candidates for the
* locale (`pickSpeechVoice`), and assign the winner. The voice list is async
* (empty on the first call, and on iOS Safari it can stay empty until the
* first user-gesture speak), so we subscribe to `voiceschanged` and also
* re-read it lazily on the first toggle. The chosen voice is cached per
* locale in a ref so the ranking runs once.
*/
function useReadAloud(): {
supported: boolean;
@@ -397,6 +472,25 @@ function useReadAloud(): {
} {
const supported = useSpeechSynthesisSupported();
const [speaking, setSpeaking] = useState(false);
// Cache the resolved voice per locale so the ranking runs once.
const voiceByLocaleRef = useRef<Map<string, SpeechSynthesisVoice | null>>(
new Map(),
);
// Pre-warm the voice list + keep it fresh: the list arrives asynchronously
// and `voiceschanged` fires when the OS finishes loading installed voices.
useEffect(() => {
if (!supported) return;
const synth = window.speechSynthesis;
const refresh = () => {
// A late-arriving list can surface a better voice than an early read;
// clear the cache so the next toggle re-ranks against the full set.
voiceByLocaleRef.current.clear();
};
synth.getVoices();
synth.addEventListener?.("voiceschanged", refresh);
return () => synth.removeEventListener?.("voiceschanged", refresh);
}, [supported]);
// Stop any in-flight utterance if the bubble unmounts (thread reset, nav).
useEffect(() => {
@@ -407,6 +501,21 @@ function useReadAloud(): {
};
}, []);
const resolveVoice = useCallback(
(synth: SpeechSynthesis, lang: string): SpeechSynthesisVoice | null => {
const cached = voiceByLocaleRef.current.get(lang);
if (cached !== undefined) return cached;
const voices = synth.getVoices();
// An empty list (first call / iOS pre-gesture) is not cached so a later
// toggle re-ranks once the OS has populated the voices.
if (voices.length === 0) return null;
const chosen = pickSpeechVoice(voices, lang);
voiceByLocaleRef.current.set(lang, chosen);
return chosen;
},
[],
);
const toggle = useCallback(
(text: string, lang: string) => {
if (!supported) return;
@@ -420,13 +529,22 @@ function useReadAloud(): {
if (!trimmed) return;
synth.cancel();
const utterance = new SpeechSynthesisUtterance(trimmed);
utterance.lang = lang;
const voice = resolveVoice(synth, lang);
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang;
} else {
utterance.lang = lang;
}
// A natural cadence: default rate, neutral pitch.
utterance.rate = 1.0;
utterance.pitch = 1.0;
utterance.onend = () => setSpeaking(false);
utterance.onerror = () => setSpeaking(false);
setSpeaking(true);
synth.speak(utterance);
},
[supported, speaking],
[supported, speaking, resolveVoice],
);
return { supported, speaking, toggle };