mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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 };
|
||||
|
||||
Reference in new issue
Block a user