diff --git a/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts b/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts new file mode 100644 index 000000000..210083104 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/read-aloud-voice.test.ts @@ -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(); + }); +}); diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index b9cbd0be3..63f624e33 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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>( + 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 };