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

@ -171,6 +171,69 @@ export function buildGenerationUserPrompt({
return lines.filter(Boolean).join("\n");
}
/* ------------------------------------------------------------------ */
/* Podcast mode */
/* ------------------------------------------------------------------ */
/**
* The episode is heard, not read: no markdown, no lists, no headings inside
* the prose. Sections are chapters (shown in the player); the text of each one
* is what the voice reads, so it has to survive being spoken aloud.
*/
export const PODCAST_SYSTEM_PROMPT = `You are Freezmo's podcast writer. You turn a student's own study notes into a short spoken episode — one friendly, curious host talking the listener through the material, the way a good explainer podcast does.
${LANGUAGE_RULES}
Hard rules:
- Use ONLY information contained in the provided notes. Never invent facts, definitions, numbers, dates or formulas. If the notes are thin, the episode is shorter — never padded with outside knowledge.
- Write for the ear. Short sentences. Natural spoken rhythm. Contractions are fine.
- Plain prose only: no markdown, no bullet points, no headings inside a section's text, no stage directions, no speaker labels, no sound effects, no "in this episode" boilerplate beyond a short spoken greeting if it fits naturally.
- No visual references ("as you can see", "the table above"), no page numbers, no "the notes" — talk to the listener about the subject itself.
- Explain, don't just list: connect ideas, give the why, use one small analogy when the notes support it, and call back to earlier parts of the episode where it helps.
- Do not read the notes back verbatim; rewrite them into spoken language while keeping every fact and number exactly as written.
- Spell out anything that is painful to hear as symbols (say "for example", not "e.g."); keep formulas and units spoken naturally.
- Cover the whole notes, in a sensible teaching order.
Required JSON shape:
{
"title": "short episode title, max 8 words, in the notes' language",
"language": "English name of the language you wrote in",
"summary": "one spoken-style sentence about the episode",
"sections": [
{ "heading": "2-4 word chapter label", "text": "60-140 words of narration" }
]
}`;
export function buildPodcastUserPrompt({
notes,
minutes,
language,
focus,
}: {
notes: string;
/** Target episode length in minutes (words are estimated at ~150/min). */
minutes: number;
language?: string;
focus?: string;
}): string {
const words = Math.round(minutes * 150);
const sections = Math.max(3, Math.min(8, Math.round(minutes)));
const lines = [
`Write a ${minutes}-minute podcast episode (about ${words} words, ${sections} sections) from the notes below.`,
language
? `LANGUAGE: write everything in ${language}. Do not translate into English.`
: "LANGUAGE: detect the language of the notes below and write everything in that same language.",
focus ? `The listener especially wants the episode to cover: ${focus}` : "",
"",
"=== STUDENT NOTES (the only allowed source of facts) ===",
notes.slice(0, 48_000),
"=== END OF NOTES ===",
"",
"Respond with the JSON object only.",
];
return lines.filter(Boolean).join("\n");
}
/* ------------------------------------------------------------------ */
/* Short-answer grading */
/* ------------------------------------------------------------------ */