freezmo/lib/speech.ts
Martin Dvorak d95c5c7151 Podcast mode: notes read aloud with a lyric-style word highlight
The third segment of the note-powered wizards (Study deck / Homework /
Podcast) takes the same intake - photos, documents, pasted text, the share
target - and turns it into a spoken episode.

- prompts: PODCAST_SYSTEM_PROMPT writes for the ear (plain prose, no markdown,
  no visual references), chaptered into 3-8 sections around a length target.
- /api/podcast mirrors /api/generate's plumbing (rate limit, model gate,
  provider keys, heartbeats, the 2x deadline, the optional vision pass).
- storage: frezmo.podcasts.v1, zod-validated, included in the backup and the
  encrypted sync payload; a new library tab lists episodes with listen /
  rename / delete-with-undo, and the wizard switch deep-links in.
- player: the transcript is word-wrapped spans and the word under the voice
  rides a lyric-style highlight that keeps itself centred. Two clocks: exact
  onboundary events where the browser reports them, a per-word length timer
  where it does not (iOS Safari); the first boundary retires the timer.
  Pause = cancel + re-speak from the word, which makes resume, chapter jumps
  and tap-any-word-to-seek the same operation; the position saves on pause,
  page-hide and unmount, so reopening resumes. Speed rebuilds the engine and
  keeps playing; the Aero soundtrack pauses itself when the voice starts; a
  browser with no voice gets an honest note and a readable transcript.
- the script sits on a glass pane (plain paragraphs over Aero's grass failed
  contrast; the scan now covers the player too).

tests/podcast-test.mjs (17th suite) stubs the model and the speech engine and
drives the highlight by hand: 26 checks. All suites green, contrast clean.
Version 0.7.3, SW v95.
2026-10-16 11:42:15 +00:00

151 lines
4.2 KiB
TypeScript

"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).
* Shared with the podcast player (lib/podcast-speech). */
export 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);
}