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:
parent
84a94581dc
commit
d95c5c7151
30 changed files with 2575 additions and 26 deletions
|
|
@ -15,9 +15,10 @@ import { applyTheme, type Theme } from "./theme";
|
|||
import { chatHistoryStore, ensureChatsHydrated, replaceChats } from "./chat-store";
|
||||
import { replaceFolders, folderStore } from "./folders";
|
||||
import { homeworkStore, replaceHomework } from "./homework";
|
||||
import { podcastStore, replacePodcasts } from "./podcasts";
|
||||
import { ensureLearningHydrated, learningStore, replaceLearning } from "./history";
|
||||
import { setLocale, t } from "./i18n";
|
||||
import { folderSchema, deckSchema, homeworkRecordSchema, answerRecordSchema, srsRecordSchema, chatConversationSchema } from "./schemas";
|
||||
import { folderSchema, deckSchema, homeworkRecordSchema, podcastSchema, answerRecordSchema, srsRecordSchema, chatConversationSchema } from "./schemas";
|
||||
import { saveSettings, settingsStore } from "./settings";
|
||||
import { deckStore, replaceDecks } from "./storage";
|
||||
|
||||
|
|
@ -34,6 +35,8 @@ export const backupDataSchema = z.object({
|
|||
decks: z.array(deckSchema).max(2000),
|
||||
folders: z.array(folderSchema).max(500),
|
||||
homework: z.array(homeworkRecordSchema).max(2000),
|
||||
/** Podcast episodes. Optional for old backups. */
|
||||
podcasts: z.array(podcastSchema).max(2000).optional(),
|
||||
/** Answer log and SRS schedule (IndexedDB). Optional for old backups. */
|
||||
history: z.array(answerRecordSchema).max(20_000).optional(),
|
||||
srs: z.array(srsRecordSchema).max(50_000).optional(),
|
||||
|
|
@ -67,6 +70,7 @@ export async function buildBackup(): Promise<BackupFile> {
|
|||
decks: deckStore.getSnapshot(),
|
||||
folders: folderStore.getSnapshot(),
|
||||
homework: homeworkStore.getSnapshot(),
|
||||
podcasts: podcastStore.getSnapshot(),
|
||||
history: learningStore.getAnswersSnapshot(),
|
||||
srs: learningStore.getSrsSnapshot(),
|
||||
chats: chatHistoryStore.getSnapshot(),
|
||||
|
|
@ -143,6 +147,7 @@ export type ImportPreview = {
|
|||
decks: ImportCounts;
|
||||
folders: ImportCounts;
|
||||
homework: ImportCounts;
|
||||
podcasts: ImportCounts;
|
||||
chats: ImportCounts;
|
||||
};
|
||||
|
||||
|
|
@ -151,6 +156,7 @@ export function previewImport(backup: BackupFile): ImportPreview {
|
|||
decks: countMerge(deckStore.getSnapshot(), backup.data.decks),
|
||||
folders: countMerge(folderStore.getSnapshot(), backup.data.folders),
|
||||
homework: countMerge(homeworkStore.getSnapshot(), backup.data.homework),
|
||||
podcasts: countMerge(podcastStore.getSnapshot(), backup.data.podcasts ?? []),
|
||||
chats: countMerge(chatHistoryStore.getSnapshot(), backup.data.chats ?? []),
|
||||
};
|
||||
}
|
||||
|
|
@ -167,12 +173,15 @@ export async function applyImport(
|
|||
mode === "replace" ? data.folders : mergeById(folderStore.getSnapshot(), data.folders);
|
||||
const homework =
|
||||
mode === "replace" ? data.homework : mergeById(homeworkStore.getSnapshot(), data.homework);
|
||||
const podcasts =
|
||||
mode === "replace" ? data.podcasts ?? [] : mergeById(podcastStore.getSnapshot(), data.podcasts ?? []);
|
||||
|
||||
// Folders first so imported decks can point at them. Importing is a real
|
||||
// local change, so it also marks sync dirty (via the stores' write()).
|
||||
replaceFolders(folders);
|
||||
replaceDecks(decks);
|
||||
replaceHomework(homework);
|
||||
replacePodcasts(podcasts);
|
||||
|
||||
// Answer log + schedule: merge by id (answers) and by key (schedules), or
|
||||
// replace wholesale when mode === "replace".
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue