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.
151 lines
4.2 KiB
TypeScript
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);
|
|
}
|