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

172
lib/podcasts.ts Normal file
View file

@ -0,0 +1,172 @@
"use client";
/**
* Podcast episodes backed by localStorage — the same tiny observable store as
* the homework history. An episode is the script the voice reads, the notes it
* came from (so it can be regenerated) and the word the listener got to.
*
* Photos and documents were reduced to text on the client before this point;
* originals are never stored.
*/
import { podcastSchema, type Podcast } from "./schemas";
import { markSyncDirty } from "./sync-state";
const STORAGE_KEY = "freezmo.podcasts.v1";
const EMPTY: Podcast[] = [];
/** Enough to regenerate from, while staying well under the storage quota. */
export const MAX_STORED_SOURCE = 24_000;
type Listener = () => void;
let episodes: Podcast[] | null = null;
let cached: Podcast[] = EMPTY;
const listeners = new Set<Listener>();
function read(): Podcast[] {
if (typeof window === "undefined") return EMPTY;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return EMPTY;
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return EMPTY;
const valid: Podcast[] = [];
for (const entry of parsed) {
const result = podcastSchema.safeParse(entry);
if (result.success) valid.push(result.data);
else console.warn("[freezmo] skipping invalid podcast", result.error.issues);
}
return valid;
} catch (error) {
console.warn("[freezmo] could not read the podcasts", error);
return EMPTY;
}
}
function ensureLoaded(): void {
if (episodes !== null) return;
episodes = read();
cached = episodes;
}
function write(next: Podcast[]): void {
episodes = next;
cached = next;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch (error) {
console.warn("[freezmo] could not save the podcasts (storage full?)", error);
}
/* An episode change with sync on pushes itself (see lib/sync-auto). */
if (markSyncDirty()) void import("./sync-auto").then((module) => module.scheduleAutoSync());
listeners.forEach((listener) => listener());
}
/** Used by backup import and sync: wholesale replacement of the list. */
export function replacePodcasts(next: Podcast[]): void {
const valid: Podcast[] = [];
for (const entry of next) {
const result = podcastSchema.safeParse(entry);
if (result.success) valid.push(entry);
else console.warn("[freezmo] skipping invalid podcast in import", result.error.issues);
}
write(valid);
}
function onStorage(event: StorageEvent): void {
if (event.key === STORAGE_KEY) {
episodes = null;
ensureLoaded();
listeners.forEach((listener) => listener());
}
}
export const podcastStore = {
subscribe(listener: Listener): () => void {
ensureLoaded();
if (listeners.size === 0) window.addEventListener("storage", onStorage);
listeners.add(listener);
return () => {
listeners.delete(listener);
if (listeners.size === 0) window.removeEventListener("storage", onStorage);
};
},
getSnapshot(): Podcast[] {
ensureLoaded();
return cached;
},
getServerSnapshot(): Podcast[] {
return EMPTY;
},
};
/** Newest first. */
export function sortPodcasts(list: Podcast[]): Podcast[] {
return [...list].sort((a, b) => b.updatedAt - a.updatedAt);
}
export function getPodcast(id: string): Podcast | undefined {
return podcastStore.getSnapshot().find((episode) => episode.id === id);
}
export function upsertPodcast(episode: Podcast): void {
ensureLoaded();
const current = podcastStore.getSnapshot();
const index = current.findIndex((entry) => entry.id === episode.id);
const next = index === -1 ? [episode, ...current] : current.map((entry) => (entry.id === episode.id ? episode : entry));
write(next);
}
export function renamePodcast(id: string, title: string): void {
ensureLoaded();
write(
podcastStore.getSnapshot().map((entry) =>
entry.id === id ? { ...entry, title, updatedAt: Date.now() } : entry,
),
);
}
export function deletePodcast(id: string): void {
ensureLoaded();
write(podcastStore.getSnapshot().filter((entry) => entry.id !== id));
}
/**
* Where the listener got to. Written on pause/leave rather than per word, so
* a long episode does not thrash localStorage.
*/
export function savePodcastProgress(id: string, progress: { section: number; word: number }): void {
ensureLoaded();
const current = podcastStore.getSnapshot();
const episode = current.find((entry) => entry.id === id);
if (!episode) return;
const at = Date.now();
const previous = episode.progress;
/* First words of a section are the same place a retry would start: skip the
write when nothing meaningfully moved. */
if (previous && previous.section === progress.section && Math.abs(previous.word - progress.word) < 2) {
return;
}
const next = current.map((entry) =>
entry.id === id ? { ...entry, progress: { ...progress, at } } : entry,
);
/* updatedAt deliberately untouched: resuming must not reorder the library. */
episodes = next;
cached = next;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* storage full — progress is a nicety, not worth a warning */
}
}
/** Rough spoken length of the whole script, in minutes (150 words a minute). */
export function podcastMinutes(episode: Podcast): number {
const words = episode.sections.reduce((total, section) => total + section.text.split(/\s+/).filter(Boolean).length, 0);
return Math.max(1, Math.round(words / 150));
}
/** The episode's script as one string (used by tests and fallbacks). */
export function podcastScript(episode: Podcast): string {
return episode.sections.map((section) => section.text).join("\n\n");
}