add language learner project
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
Some checks failed
Test and deploy staging / staging (push) Has been cancelled
Dutch and Korean (explained in English) and English (explained in Dutch) from A1 to C1, with a Hangul level for Korean. Lessons with notes, words and sentences generate multiple-choice, typing, sentence building, cloze, listening and matching exercises. Endless challenges: a seeded daily challenge, sprint, survival, mixed practice, level tests, spaced-repetition reviews and generated drills (numbers, clock times, de/het, plurals, Dutch and Korean conjugation, particles, Hangul reading, irregular verbs). Progress is stored in localStorage with export/import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
43
src/lib/language-learner/speech.ts
Normal file
43
src/lib/language-learner/speech.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** Text-to-speech through the browser's built-in voices; no audio files or services. */
|
||||
|
||||
const supported = () => typeof window !== 'undefined' && 'speechSynthesis' in window;
|
||||
|
||||
function voices(): Promise<SpeechSynthesisVoice[]> {
|
||||
if (!supported()) return Promise.resolve([]);
|
||||
const now = speechSynthesis.getVoices();
|
||||
if (now.length) return Promise.resolve(now);
|
||||
return new Promise((resolve) => {
|
||||
const done = () => resolve(speechSynthesis.getVoices());
|
||||
speechSynthesis.addEventListener('voiceschanged', done, { once: true });
|
||||
setTimeout(done, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
const matches = (voice: SpeechSynthesisVoice, lang: string) =>
|
||||
voice.lang.replace('_', '-').toLowerCase().startsWith(lang.slice(0, 2).toLowerCase());
|
||||
|
||||
function best(list: SpeechSynthesisVoice[], lang: string) {
|
||||
const candidates = list.filter((v) => matches(v, lang));
|
||||
return (
|
||||
candidates.find(
|
||||
(v) => v.lang.replace('_', '-').toLowerCase() === lang.toLowerCase() && !v.localService
|
||||
) ??
|
||||
candidates.find((v) => v.lang.replace('_', '-').toLowerCase() === lang.toLowerCase()) ??
|
||||
candidates[0]
|
||||
);
|
||||
}
|
||||
|
||||
export async function hasVoice(lang: string): Promise<boolean> {
|
||||
return !!best(await voices(), lang);
|
||||
}
|
||||
|
||||
export async function speak(text: string, lang: string, rate = 0.9) {
|
||||
if (!supported()) return;
|
||||
const voice = best(await voices(), lang);
|
||||
speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = lang;
|
||||
utterance.rate = rate;
|
||||
if (voice) utterance.voice = voice;
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
Reference in New Issue
Block a user