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.
This commit is contained in:
Martin Dvořák 2026-10-16 11:42:15 +00:00
commit d95c5c7151
30 changed files with 2575 additions and 26 deletions

View file

@ -162,7 +162,7 @@ const en: Record<string, string> = {
"settings.exportAll": "Export all data",
"settings.exportNote": "Decks, homework, progress and settings — never your API key.",
"settings.importBackup": "Import backup",
"settings.importPreview": "This backup has {decks} decks, {homework} homework results, {folders} folders and {chats} chats.",
"settings.importPreview": "This backup has {decks} decks, {homework} homework results, {podcasts} podcasts, {folders} folders and {chats} chats.",
"settings.importMergeNote": "Merge keeps what's here and adds the backup; matching IDs are replaced.",
"settings.importReplaceNote": "Replace wipes this browser's data and uses the backup instead.",
"settings.importMerge": "Merge",
@ -274,7 +274,61 @@ const en: Record<string, string> = {
"homework.nav": "New homework",
"wizard.mode.deck": "Study deck",
"wizard.mode.homework": "Make my homework",
"wizard.mode.aria": "Study deck or homework",
"wizard.mode.podcast": "Podcast",
"wizard.mode.aria": "Study deck, homework or podcast",
/* Podcast mode (notes → a spoken episode with a highlighted transcript) */
"podcast.title": "New podcast",
"podcast.subtitle": "Freezmo reads your notes back to you as a short episode — and highlights every word as it is spoken, like lyrics.",
"podcast.addNotes": "Add your notes",
"podcast.addPhotos": "Photos",
"podcast.addDocs": "Documents",
"podcast.addFirst": "Add notes first",
"podcast.generate": "Create the episode",
"podcast.lengthAria": "Episode length",
"podcast.lengthHelp": "A rough target — the script stays as long as the notes need.",
"podcast.focusLabel": "What should it focus on? (optional)",
"podcast.minutes.one": "{n} minute",
"podcast.minutes.few": "{n} minutes",
"podcast.minutes.many": "{n} minutes",
"podcast.sectionCount.one": "{n} section",
"podcast.sectionCount.few": "{n} sections",
"podcast.sectionCount.many": "{n} sections",
"podcast.wordCount.one": "{n} word",
"podcast.wordCount.few": "{n} words",
"podcast.wordCount.many": "{n} words",
"podcast.sectionOf": "Part {n} of {total}",
"podcast.play": "Play the episode",
"podcast.pause": "Pause the episode",
"podcast.restart": "Start from the beginning",
"podcast.speedAria": "Playback speed",
"podcast.tapToJump": "Tap any word to jump there.",
"podcast.noVoice": "This browser has no speech voice installed, so the transcript cannot be read aloud here. You can still read along.",
"podcast.loadingEpisode": "Loading the episode",
"podcast.notFound": "Episode not found",
"podcast.notFoundBody": "It was probably deleted, or it lives in another browser's storage.",
"podcast.loading.read1": "Reading your notes…",
"podcast.loading.read2": "Making sense of the pages…",
"podcast.loading.write1": "Writing the script…",
"podcast.loading.write2": "Finding the through-line…",
"podcast.loading.write3": "Polishing the spoken bits…",
"podcast.tip1": "The transcript highlights every word as it is read — tap any word to jump there.",
"podcast.tip2": "A four-minute walk is a great length for one subject.",
/* Library: the podcasts tab */
"tabPodcasts": "Podcasts",
"podcast.library.empty": "No episodes yet",
"podcast.library.emptyBody": "Turn a page of notes into a spoken episode and it will wait here.",
"podcast.library.new": "New podcast",
"podcast.card.sections": "{n} parts",
"podcast.card.minutes": "~{n} min",
"podcast.card.actions": "Episode actions",
"podcast.card.open": "Listen",
"podcast.card.rename": "Rename",
"podcast.card.delete": "Delete episode",
"podcast.card.deleted": "Episode deleted",
"podcast.card.renamed": "Episode renamed",
"podcast.regenerate": "Write it again",
"homeworkHeroTitle": "Make my homework",
"homework.subtitle": "Paste it, photograph it or add a file — then pick how I should help.",
"homework.phone.assignment": "The assignment",
@ -1175,7 +1229,7 @@ const cs: Record<string, string> = {
"settings.exportAll": "Exportovat všechna data",
"settings.exportNote": "Sady, úkoly, pokrok a nastavení — nikdy tvůj API klíč.",
"settings.importBackup": "Importovat zálohu",
"settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů, {folders} složek a {chats} chatů.",
"settings.importPreview": "Záloha obsahuje {decks} sad, {homework} výsledků úkolů, {podcasts} podcastů, {folders} složek a {chats} chatů.",
"settings.importMergeNote": "Sloučení ponechá stávající data a přidá zálohu; stejná ID se nahradí.",
"settings.importReplaceNote": "Nahrazení smaže data v tomto prohlížeči a použije zálohu.",
"settings.importMerge": "Sloučit",
@ -1287,7 +1341,61 @@ const cs: Record<string, string> = {
"homework.nav": "Nový úkol",
"wizard.mode.deck": "Studijní sada",
"wizard.mode.homework": "Udělej mi úkol",
"wizard.mode.aria": "Studijní sada nebo úkol",
"wizard.mode.podcast": "Podcast",
"wizard.mode.aria": "Studijní sada, úkol nebo podcast",
/* Podcast mode (notes → a spoken episode with a highlighted transcript) */
"podcast.title": "Nový podcast",
"podcast.subtitle": "Freezmo ti tvoje poznámky přečte jako krátkou epizodu — a každé slovo při čtení zvýrazní, jako texty písní.",
"podcast.addNotes": "Přidej poznámky",
"podcast.addPhotos": "Fotky",
"podcast.addDocs": "Dokumenty",
"podcast.addFirst": "Nejdřív přidej poznámky",
"podcast.generate": "Vytvořit epizodu",
"podcast.lengthAria": "Délka epizody",
"podcast.lengthHelp": "Jen orientační cíl — scénář zůstane tak dlouhý, jak poznámky potřebují.",
"podcast.focusLabel": "Na co se má zaměřit? (nepovinné)",
"podcast.minutes.one": "{n} minuta",
"podcast.minutes.few": "{n} minuty",
"podcast.minutes.many": "{n} minut",
"podcast.sectionCount.one": "{n} část",
"podcast.sectionCount.few": "{n} části",
"podcast.sectionCount.many": "{n} částí",
"podcast.wordCount.one": "{n} slovo",
"podcast.wordCount.few": "{n} slova",
"podcast.wordCount.many": "{n} slov",
"podcast.sectionOf": "Část {n} z {total}",
"podcast.play": "Přehrát epizodu",
"podcast.pause": "Pozastavit epizodu",
"podcast.restart": "Začít od začátku",
"podcast.speedAria": "Rychlost přehrávání",
"podcast.tapToJump": "Klikni na kterékoli slovo a přeskočíš tam.",
"podcast.noVoice": "Tento prohlížeč nemá nainstalovaný hlas, takže se přepis nedá přečíst nahlas. Číst ho ale můžeš dál.",
"podcast.loadingEpisode": "Načítání epizody",
"podcast.notFound": "Epizoda nenalezena",
"podcast.notFoundBody": "Nejspíš byla smazaná, nebo je uložená v jiném prohlížeči.",
"podcast.loading.read1": "Čtu tvoje poznámky…",
"podcast.loading.read2": "Dávám stránky dohromady…",
"podcast.loading.write1": "Píšu scénář…",
"podcast.loading.write2": "Hledám hlavní linii…",
"podcast.loading.write3": "Ladím mluvené pasáže…",
"podcast.tip1": "Přepis zvýrazňuje každé čtené slovo — kliknutím na slovo se tam přesuneš.",
"podcast.tip2": "Čtyřminutová procházka je pro jedno téma tak akorát.",
/* Library: the podcasts tab */
"tabPodcasts": "Podcasty",
"podcast.library.empty": "Zatím žádné epizody",
"podcast.library.emptyBody": "Proměň stránku poznámek v mluvenou epizodu a počká tady na tebe.",
"podcast.library.new": "Nový podcast",
"podcast.card.sections": "{n} částí",
"podcast.card.minutes": "~{n} min",
"podcast.card.actions": "Akce epizody",
"podcast.card.open": "Poslouchat",
"podcast.card.rename": "Přejmenovat",
"podcast.card.delete": "Smazat epizodu",
"podcast.card.deleted": "Epizoda smazána",
"podcast.card.renamed": "Epizoda přejmenována",
"podcast.regenerate": "Napsat znovu",
"homeworkHeroTitle": "Udělej mi úkol",
"homework.subtitle": "Vlož ho, vyfoť nebo přidej soubor — a vyber, jak ti mám pomoct.",
"homework.phone.assignment": "Zadání",