Study cards: from-your-notes, read aloud, timed runs

From your notes: the generator now stores a verbatim source quote with every
activity (schema + prompt + a server-side clamp so a chatty model cannot drop a
card), and the feedback panel shows the sentence that carries it with the quote
highlighted green. Decks made before the change, and quotes the model trimmed,
fall back to a local IDF-weighted match over the saved notes text
(lib/notes-source.ts): nothing is shown unless at least two distinct terms
land. The passage hides in exam mode with the other reveals.

Read aloud: a Web Speech API speaker control on the text a learner reads
(flashcard faces, questions, statements, sentences, instructions) via
lib/speech.ts and components/SpeakButton.tsx — one utterance app-wide, a voice
matched to the deck's language, and no button at all where the browser has no
speech synthesis. It used to sit inside the flashcard's flip button (nested
buttons, tapping it flipped the card); it now lives next to the hint row.

Timed runs: the feed chrome carries a timer chip (off by default) that gives
each card 30 seconds; a card that runs out is recorded as a miss through the
same path as a wrong answer (history + SRS agree), shows a toast, and the feed
moves straight on with a fresh budget. handleAnswered ignores a second result
per card, so a timeout plus a manual answer cannot record twice.

The generator's type spec now names what each type suits (language decks lean
on flashcards/matching/fill blank, ordering needs a real sequence, short answer
reasoning), so the deck-aware guidance covers all seven types.

New activities-test suite (24 checks: quote path, local path, the green mark,
the speech stub, the timed run on Playwright's fake clock), registered in
run-all; phase2 pins that the notes passage stays hidden in exam mode; en+cs
keys for every new string; no new dependencies. Version 0.6.1, SW v83.
This commit is contained in:
Martin Dvořák 2026-10-15 05:53:47 +00:00
commit 796e2e9fe9
26 changed files with 1068 additions and 51 deletions

View file

@ -630,24 +630,40 @@ export async function refineHomeworkText(input: {
* deduping — a shorter list would fail validation on the next read and take
* the whole deck down with it.
*/
/**
* The "source" quote is shown verbatim (and matched against the stored notes),
* so a model that ignores the 25-word rule gets clamped to a clean, short
* quote: whitespace collapsed, cut at a sentence or word boundary.
*/
function clampQuote(quote: string): string {
const clean = quote.replace(/\s+/g, " ").trim();
if (clean.length <= 240) return clean;
const cut = clean.slice(0, 240);
const boundary = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("! "), cut.lastIndexOf("? "));
if (boundary > 80) return cut.slice(0, boundary + 1);
const space = cut.lastIndexOf(" ");
return space > 40 ? cut.slice(0, space) : cut;
}
function sanitiseActivity(activity: GeneratedActivity): GeneratedActivity {
switch (activity.type) {
const sanitised = activity.source ? { ...activity, source: clampQuote(activity.source) } : activity;
switch (sanitised.type) {
case "ordering": {
const items = dedupeBy(activity.items, (item) => item);
return items.length === activity.items.length || items.length < 3 ? activity : { ...activity, items };
const items = dedupeBy(sanitised.items, (item) => item);
return items.length === sanitised.items.length || items.length < 3 ? sanitised : { ...sanitised, items };
}
case "fill_blank": {
if (/_{2,}/.test(activity.sentence)) return activity;
return { ...activity, sentence: `${activity.sentence.trim()} ____` };
if (/_{2,}/.test(sanitised.sentence)) return sanitised;
return { ...sanitised, sentence: `${sanitised.sentence.trim()} ____` };
}
case "matching": {
// Terms first, then definitions: one definition per term, or the learner
// cannot tell the pairs apart.
const pairs = dedupeBy(dedupeBy(activity.pairs, (pair) => pair.term), (pair) => pair.definition);
return pairs.length === activity.pairs.length || pairs.length < 3 ? activity : { ...activity, pairs };
const pairs = dedupeBy(dedupeBy(sanitised.pairs, (pair) => pair.term), (pair) => pair.definition);
return pairs.length === sanitised.pairs.length || pairs.length < 3 ? sanitised : { ...sanitised, pairs };
}
default:
return activity;
return sanitised;
}
}

View file

@ -535,6 +535,9 @@ const en: Record<string, string> = {
"feed.results": "Results",
"feed.report": "Report question",
"feed.reported": "Question reported — it won't show up again.",
"feed.timed": "Timed run · 30s per card",
"feed.timedOn": "Timed run on — a card that runs out counts as a miss",
"feed.timedOut": "Time's up — the card counts as a miss.",
/* results --------------------------------------------------------- */
"results.perfect": "Perfect freeze! 🧊",
@ -656,6 +659,9 @@ const en: Record<string, string> = {
"activity.next": "Next",
"activity.correctAnswer": "Correct answer:",
"activity.unsupported": "Unsupported activity: {json}",
"activity.source": "From your notes",
"activity.speak": "Read aloud",
"activity.stopSpeaking": "Stop reading",
/* flashcard ------------------------------------------------------- */
"flashcard.tapToFlip": "Tap the card to flip it.",
@ -1524,6 +1530,9 @@ const cs: Record<string, string> = {
"feed.prev": "Předchozí aktivita",
"feed.next": "Další aktivita",
"feed.results": "Výsledky",
"feed.timed": "Kolo na čas · 30 s na kartu",
"feed.timedOn": "Na čas — kartě, které vyprší čas, se počítá jako chyba",
"feed.timedOut": "Čas vypršel — karta se počítá jako chyba.",
"feed.report": "Nahlásit otázku",
"feed.reported": "Otázka nahlášena — už se neukáže.",
@ -1647,6 +1656,9 @@ const cs: Record<string, string> = {
"activity.next": "Další",
"activity.correctAnswer": "Správná odpověď:",
"activity.unsupported": "Nepodporovaná aktivita: {json}",
"activity.source": "Z tvých poznámek",
"activity.speak": "Přečíst nahlas",
"activity.stopSpeaking": "Zastavit čtení",
/* flashcard ------------------------------------------------------- */
"flashcard.tapToFlip": "Klepnutím na kartu ji otočíš.",

309
lib/notes-source.ts Normal file
View file

@ -0,0 +1,309 @@
/**
* "Where in my notes did this come from?" — the study feed shows the passage
* that carries an activity's answer, with the matching words highlighted.
*
* Two paths, both local (nothing leaves the browser):
* - activities generated after the source-quote change carry a verbatim
* `source` quote; it is located in the stored notes and the sentence around
* it is shown, with the quote itself highlighted;
* - older activities have no quote, so the passage is found by scoring the
* notes' sentences against the activity's own wording. Rarer words weigh
* more (an IDF sum), so "Rayleigh" beats "the", and a sentence has to match
* at least two distinct terms to count.
*
* Returns null when nothing convincing is found — a random sentence presented
* as "where this came from" would be worse than showing nothing.
*/
import type { Activity } from "./schemas";
export type NotesMatch = {
/** The passage to show. */
text: string;
/** Highlight spans inside `text` (start, end). */
spans: Array<[number, number]>;
/** True when the AI supplied the quote (vs. a local sentence match). */
quote: boolean;
};
/** Longest excerpt shown under an activity. */
const MAX_EXCERPT = 280;
const STOPWORDS = new Set([
"the", "and", "that", "with", "from", "this", "which", "are", "was", "were", "have", "has", "had",
"for", "not", "but", "you", "your", "they", "their", "into", "than", "then", "what", "when",
"where", "why", "how", "does", "did", "can", "could", "should", "would", "its", "one", "two",
"about", "over", "under", "more", "most", "some", "any", "all", "each", "also", "such", "only",
"very", "just", "like", "make", "made", "use", "used", "using", "how", "who", "whose", "will",
]);
const isWordCharacter = (character: string | undefined) =>
Boolean(character && /[\p{L}\p{N}]/u.test(character));
/** Lower-case the words of a text, for term comparison. */
export function termsOf(text: string): string[] {
return [
...new Set(
text
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((word) => word.length >= 3 && !STOPWORDS.has(word)),
),
];
}
/**
* The activity's own wording, used when no quote is stored: the question side
* plus the answer side, so both "what was asked" and "what the answer mentions"
* can hit a sentence in the notes.
*/
export function activityQuery(activity: Activity): string {
switch (activity.type) {
case "flashcard":
return `${activity.front} ${activity.back}`;
case "multiple_choice":
return `${activity.question} ${activity.options[activity.correctIndex] ?? ""}`;
case "true_false":
return activity.statement;
case "fill_blank":
return `${activity.sentence.replace(/_{2,}/g, " ")} ${activity.answer}`;
case "matching":
return activity.pairs.map((pair) => `${pair.term} ${pair.definition}`).join(" ");
case "ordering":
return activity.items.join(" ");
case "short_answer":
return `${activity.question} ${activity.referenceAnswer} ${activity.keyPoints.join(" ")}`;
default:
return "";
}
}
/** `text` plus a per-character index map into the original. */
function normalise(text: string): { text: string; map: number[] } {
let out = "";
const map: number[] = [];
let pendingSpace = false;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (/\s/.test(character)) {
pendingSpace = out.length > 0;
continue;
}
if (pendingSpace) {
out += " ";
map.push(index);
pendingSpace = false;
}
out += character.toLowerCase();
map.push(index);
}
return { text: out, map };
}
/** The quote (normalised) inside the notes, or the longest verbatim word run. */
function locateQuote(notes: string, quote: string): [number, number] | null {
const haystack = normalise(notes);
const needle = normalise(quote).text.replace(/…/g, "").trim();
if (needle.length < 8) return null;
const span = (from: number, to: number): [number, number] => [
haystack.map[from],
haystack.map[to - 1] + 1,
];
const exact = haystack.text.indexOf(needle);
if (exact >= 0) return span(exact, exact + needle.length);
// The model paraphrased or trimmed the quote: fall back to the longest run
// of consecutive words that does appear verbatim.
const words = needle.split(" ").filter(Boolean);
for (let size = Math.min(words.length, 16); size >= 3; size -= 1) {
for (let start = 0; start + size <= words.length; start += 1) {
const chunk = words.slice(start, start + size).join(" ");
const at = haystack.text.indexOf(chunk);
if (at >= 0) return span(at, at + chunk.length);
}
}
return null;
}
/** Expand a match to the sentence (or line) around it. */
function sentenceAround(text: string, start: number, end: number): [number, number] {
let from = start;
while (from > 0) {
const previous = text[from - 1];
if (previous === "\n" || previous === "." || previous === "!" || previous === "?") break;
from -= 1;
}
let to = end;
while (to < text.length) {
const character = text[to];
if (character === "\n" || character === "." || character === "!" || character === "?") {
to += 1;
break;
}
to += 1;
}
while (from < to && /\s/.test(text[from])) from += 1;
while (to > from && /\s/.test(text[to - 1])) to -= 1;
return [from, to];
}
/** Trim a passage to `max` characters around the first highlight. */
function clip(
text: string,
start: number,
end: number,
spans: Array<[number, number]>,
max: number,
): { text: string; spans: Array<[number, number]> } | null {
const length = end - start;
if (length <= max) {
return {
text: text.slice(start, end),
spans: spans
.map(([from, to]) => [Math.max(start, from), Math.min(end, to)] as [number, number])
.filter(([from, to]) => to > from)
.map(([from, to]) => [from - start, to - start] as [number, number]),
};
}
const anchor = spans[0] ?? [start, start];
const from = Math.max(start, Math.min(anchor[0] - Math.floor(max / 3), end - max));
const to = Math.min(end, from + max);
const prefix = from > start ? "… " : "";
const suffix = to < end ? " …" : "";
const trimmed = `${prefix}${text.slice(from, to).trim()}${suffix}`;
const shift = from + prefix.length;
return {
text: trimmed,
spans: spans
.map(([spanFrom, spanTo]) => [Math.max(from, spanFrom) - shift, Math.min(to, spanTo) - shift] as [number, number])
.filter(([spanFrom, spanTo]) => spanTo > spanFrom && spanFrom >= 0 && spanTo <= trimmed.length),
};
}
/** Sentence split that keeps offsets; no lookbehind (older Safari). */
function sentences(text: string): Array<{ start: number; end: number }> {
const out: Array<{ start: number; end: number }> = [];
let start = 0;
const push = (end: number) => {
let from = start;
while (from < end && /\s/.test(text[from])) from += 1;
let to = end;
while (to > from && /\s/.test(text[to - 1])) to -= 1;
if (to - from >= 8) out.push({ start: from, end: to });
start = end;
};
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === "\n") {
push(index);
start = index + 1;
continue;
}
if ((character === "." || character === "!" || character === "?") && /\s/.test(text[index + 1] ?? "\n")) {
push(index + 1);
start = index + 1;
}
}
push(text.length);
return out;
}
/** Every occurrence of the matched terms in a sentence (word boundaries). */
function termSpans(text: string, matched: Set<string>): Array<[number, number]> {
const { text: haystack, map } = normalise(text);
const spans: Array<[number, number]> = [];
for (const term of matched) {
let at = haystack.indexOf(term);
while (at >= 0) {
if (!isWordCharacter(haystack[at - 1]) && !isWordCharacter(haystack[at + term.length])) {
spans.push([map[at], map[at + term.length - 1] + 1]);
}
at = haystack.indexOf(term, at + 1);
}
}
return spans.sort((a, b) => a[0] - b[0]);
}
/** The best-matching sentence for a query, IDF-weighted. */
export function bestSentence(notes: string, query: string): NotesMatch | null {
const list = sentences(notes);
if (list.length === 0) return null;
const queryTerms = termsOf(query);
if (queryTerms.length === 0) return null;
const perSentence = list.map((sentence) => new Set(termsOf(notes.slice(sentence.start, sentence.end))));
const documentFrequency = new Map<string, number>();
for (const unique of perSentence) {
for (const term of unique) documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
}
const total = list.length;
let best: { index: number; score: number; matched: Set<string> } | null = null;
perSentence.forEach((unique, index) => {
const matched = new Set<string>();
let score = 0;
for (const term of queryTerms) {
if (!unique.has(term)) continue;
matched.add(term);
score += Math.log(1 + total / (documentFrequency.get(term) ?? 1));
}
const enough = matched.size >= 2 || (matched.size === 1 && total <= 3);
if (!enough) return;
if (!best || score > best.score) best = { index, score, matched };
});
if (!best) return null;
const winner: { index: number; score: number; matched: Set<string> } = best;
const sentence = list[winner.index];
const spans = termSpans(notes.slice(sentence.start, sentence.end), winner.matched);
const clipped = clip(notes, sentence.start, sentence.end, spans, MAX_EXCERPT);
if (!clipped) return null;
return { text: clipped.text, spans: clipped.spans, quote: false };
}
/**
* The passage that carries this activity's answer, from the AI's own quote
* when there is one and by local matching otherwise.
*/
export function notesSource(notes: string | undefined, activity: Activity): NotesMatch | null {
const text = (notes ?? "").trim();
if (text.length < 24) return null;
if (activity.source) {
const located = locateQuote(text, activity.source);
if (located) {
const [start, end] = sentenceAround(text, located[0], located[1]);
const clipped = clip(text, start, end, [located], MAX_EXCERPT);
if (clipped) return { text: clipped.text, spans: clipped.spans, quote: true };
}
}
return bestSentence(text, activityQuery(activity));
}
/** Splits a passage into pieces for rendering, marking the highlighted ones. */
export function highlightSegments(
text: string,
spans: Array<[number, number]>,
): Array<{ text: string; hit: boolean }> {
const merged = [...spans]
.filter(([from, to]) => to > from && from >= 0 && to <= text.length)
.sort((a, b) => a[0] - b[0])
.reduce<Array<[number, number]>>((list, span) => {
const last = list[list.length - 1];
if (last && span[0] <= last[1]) last[1] = Math.max(last[1], span[1]);
else list.push([span[0], span[1]]);
return list;
}, []);
if (merged.length === 0) return [{ text, hit: false }];
const out: Array<{ text: string; hit: boolean }> = [];
let at = 0;
for (const [from, to] of merged) {
if (from > at) out.push({ text: text.slice(at, from), hit: false });
out.push({ text: text.slice(from, to), hit: true });
at = to;
}
if (at < text.length) out.push({ text: text.slice(at), hit: false });
return out;
}

View file

@ -64,28 +64,28 @@ export const VISION_USER_INSTRUCTION = `Transcribe these notes and extract the s
/* Step 2 - activity generation */
/* ------------------------------------------------------------------ */
export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type" field. Exactly these seven types exist:
export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type" field. Exactly these seven types exist. Each one names the material it suits best - pick types that fit the notes, never force a type that does not.
1. flashcard - tap to flip
1. flashcard - tap to flip. Best for language vocabulary, terms to remember and quick recall.
{ "type": "flashcard", "topic": "short label", "front": "question or term", "back": "answer or definition", "explanation": "why this matters" }
2. multiple_choice - exactly 4 options, exactly one correct
2. multiple_choice - exactly 4 options, exactly one correct. Best for single facts that are easy to confuse, and for definitions that need a plausible-sounding wrong option.
{ "type": "multiple_choice", "topic": "short label", "question": "...", "options": ["A", "B", "C", "D"], "correctIndex": 0, "explanation": "why the correct option is right and the others are wrong" }
Put the correct answer at a varied position across the deck (not always 0!).
3. true_false - one statement
3. true_false - one statement. Best for fine distinctions, exceptions and common mix-ups.
{ "type": "true_false", "topic": "short label", "statement": "...", "answer": true, "explanation": "why it is true/false" }
Roughly half of them should be false, and false statements must be subtly wrong (a swapped term, a wrong number), never absurd.
4. fill_blank - the sentence contains the placeholder "____"
4. fill_blank - the sentence contains the placeholder "____". Best for key terms, formulas and language vocabulary that has to be produced inside a sentence (genders, prepositions, endings).
{ "type": "fill_blank", "topic": "short label", "sentence": "Mitochondria produce ____ for the cell.", "answer": "ATP", "options": ["ATP", "DNA", "glucose", "water"], "explanation": "..." }
"options" is optional but preferred: 3-6 plausible choices including the answer.
5. matching - 3 to 6 term/definition pairs
5. matching - 3 to 6 term/definition pairs. Best for vocabulary, terms to definitions, symbols to meanings, words to translations, dates to events - the strongest type for language decks.
Every term and definition must be distinct: a definition describes exactly one term, so never reuse a definition for two pairs.
{ "type": "matching", "topic": "short label", "instruction": "Match each term to its definition.", "pairs": [{ "term": "Osmosis", "definition": "diffusion of water across a membrane" }], "explanation": "..." }
6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them)
6. ordering - 3 to 7 items given in the CORRECT order (the app shuffles them). Best for material with a real sequence.
Use it ONLY when the notes contain a real sequence the learner can reason about: historical or
chronological events, the stages of a process, steps of a procedure, a lifecycle, a ranked scale.
If the notes only list unrelated things (works, authors, terms, places, values), do NOT invent an
@ -95,11 +95,11 @@ export const ACTIVITY_JSON_SPEC = `Each activity is one JSON object with a "type
the learner has to work out the order from what each item says, not read it off.
{ "type": "ordering", "topic": "short label", "instruction": "Put the events in the order they happened.", "items": ["The Allies land in Normandy", "Paris is liberated", "Germany surrenders"], "explanation": "..." }
7. short_answer - typed answer, graded by AI
7. short_answer - typed answer, graded by AI. Best for asking why or how something works, or for applying a rule.
{ "type": "short_answer", "topic": "short label", "question": "...", "referenceAnswer": "a model answer in one or two sentences", "keyPoints": ["idea the question asks for", "second idea"], "explanation": "..." }
keyPoints must contain only the ideas needed to answer the question — never extras the question does not ask about (dates, places, names, numbers). For a "why" question require the reason, not the year.
Every activity must also have "topic" (2-4 words) and "explanation" (one or two sentences, plain language).`;
Every activity must also have "topic" (2-4 words), "explanation" (one or two sentences, plain language) and "source": the exact words from the notes that prove the answer — one phrase or one sentence, at most 25 words, copied verbatim (no ellipses, no edits, same language as the notes). If several places mention it, pick the clearest one. The app shows this quote back to the learner under "From your notes".`;
export const GENERATION_SYSTEM_PROMPT = `You are Freezmo's activity designer. You turn a student's own notes into a deck of short, playful, interactive study activities.
@ -109,6 +109,7 @@ Hard rules:
- Use ONLY information contained in the provided notes. Never invent facts, definitions, numbers, dates or formulas. If the notes do not contain enough material for the requested number of activities, create fewer - quality over quantity.
- Every explanation must be answerable from the notes; do not introduce outside knowledge.
- Vary the activity types. Use a healthy mix of all seven types below, and never put more than two activities of the same type in a row.
- Match the mix to the material: language decks lean on flashcards, matching and fill_blank; historical or process material on ordering and true_false; reasoning and explanations on short_answer and multiple_choice.
- Cover the whole notes, not just the first paragraph. Spread activities across the different topics, definitions, formulas and facts.
- Make the wrong options plausible (common mix-ups from the same notes), never silly filler.
- Every option, item, term and definition on a card must be unique: never repeat the same wording

View file

@ -25,6 +25,12 @@ const baseActivity = {
/** Short label shown in the feed chrome, e.g. "Cell biology". */
topic: z.string().min(1).max(120).optional(),
explanation: z.string().min(1).max(600),
/**
* The words from the student's own notes that carry this answer. The feed
* shows them back after answering ("From your notes", with the matching
* passage highlighted), so the quote has to be verbatim.
*/
source: z.string().min(1).max(1200).optional(),
};
export const flashcardSchema = z.object({

150
lib/speech.ts Normal file
View file

@ -0,0 +1,150 @@
"use client";
/**
* Text-to-speech for study cards, on the browser's own Web Speech API.
*
* No dependency and no audio leaves the app ourselves: the voice, the engine
* and any cloud fallback belong to the operating system/browser. The module
* keeps one utterance at a time app-wide (two cards reading over each other is
* never what anyone wants) and notifies subscribers so the button that started
* it can flip to "stop".
*/
const listeners = new Set<() => void>();
let speakingId: string | null = null;
function notify(): void {
listeners.forEach((listener) => listener());
}
export function speechAvailable(): boolean {
return typeof window !== "undefined" && "speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined";
}
/** Language names ("Czech") and codes ("cs") → a BCP-47 tag for the voice. */
const LOCALES: Record<string, string> = {
english: "en-US",
czech: "cs-CZ",
slovak: "sk-SK",
polish: "pl-PL",
german: "de-DE",
french: "fr-FR",
spanish: "es-ES",
italian: "it-IT",
portuguese: "pt-PT",
dutch: "nl-NL",
swedish: "sv-SE",
norwegian: "nb-NO",
danish: "da-DK",
finnish: "fi-FI",
hungarian: "hu-HU",
romanian: "ro-RO",
turkish: "tr-TR",
croatian: "hr-HR",
slovenian: "sl-SI",
serbian: "sr-RS",
russian: "ru-RU",
ukrainian: "uk-UA",
bulgarian: "bg-BG",
belarusian: "be-BY",
macedonian: "mk-MK",
greek: "el-GR",
chinese: "zh-CN",
mandarin: "zh-CN",
japanese: "ja-JP",
korean: "ko-KR",
arabic: "ar-SA",
persian: "fa-IR",
farsi: "fa-IR",
urdu: "ur-PK",
hebrew: "he-IL",
hindi: "hi-IN",
marathi: "mr-IN",
nepali: "ne-NP",
thai: "th-TH",
vietnamese: "vi-VN",
};
/**
* A BCP-47 tag for the deck's language, falling back to the interface locale
* (so an untagged deck reads with the voice the user already hears the app in).
*/
export function speechLocale(language: string | undefined, fallback: string): string {
const key = (language ?? "").trim().toLowerCase().replace(/[_.]/g, "-").split("-")[0].replace(/[^a-z]/g, "");
if (key && LOCALES[key]) return LOCALES[key];
if (key.length === 2) return language!.trim();
return fallback === "cs" ? "cs-CZ" : "en-US";
}
/** The installed voice closest to `locale` (exact match, then language). */
function pickVoice(locale: string): SpeechSynthesisVoice | null {
try {
const voices = window.speechSynthesis.getVoices();
if (voices.length === 0) return null;
const wanted = locale.toLowerCase();
const prefix = wanted.split("-")[0];
return (
voices.find((voice) => voice.lang.toLowerCase() === wanted) ??
voices.find((voice) => voice.lang.toLowerCase().replace("_", "-") === wanted) ??
voices.find((voice) => voice.lang.toLowerCase().startsWith(`${prefix}-`) || voice.lang.toLowerCase() === prefix) ??
null
);
} catch {
return null;
}
}
/**
* Reads `text` aloud under `id` (usually the button's own React id, so the
* button that started it is the one that shows "stop").
*/
export function speak(id: string, text: string, locale: string): void {
if (!speechAvailable()) return;
const clean = text.replace(/…/g, ", ").replace(/_{2,}/g, "… ").trim();
if (!clean) return;
try {
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(clean);
utterance.lang = locale;
utterance.rate = 0.95;
const voice = pickVoice(locale);
if (voice) utterance.voice = voice;
const done = () => {
if (speakingId === id) {
speakingId = null;
notify();
}
};
utterance.onend = done;
utterance.onerror = done;
speakingId = id;
notify();
window.speechSynthesis.speak(utterance);
} catch {
speakingId = null;
notify();
}
}
export function stopSpeaking(): void {
if (!speechAvailable()) return;
try {
window.speechSynthesis.cancel();
} catch {
/* already gone */
}
if (speakingId !== null) {
speakingId = null;
notify();
}
}
/** True while `id`'s utterance is the one playing. */
export function isSpeaking(id: string): boolean {
return speakingId === id;
}
export function subscribeSpeech(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}