freezmo/public/sw.js
Martin Dvorak 8e1cd0855d Podcast voices: natural provider TTS, and system-voice hygiene
Two reports, one root: the platform's speech engine.

"Loud cracking at the end of every sentence" is the browser/OS TTS popping
between its internal sentence chunks (an idle cancel makes it worse). Hygiene
for that path: never cancel when nothing is pending, normalise the spoken text
(control characters, ellipses, dashes), and a 140ms beat between sections so
speech never runs into the engine's tail.

The real fix is a second voice source that does not use the platform engine:
- lib/providers carries TTS profiles (OpenAI gpt-4o-mini-tts, Groq
  playai-tts, any OpenAI-compatible /audio/speech behind the custom provider),
  male voices first.
- /api/tts proxies them with the same guards and the key still in headers.
- the picker gains System / Natural, with the provider's voice list.
- lib/podcast-audio plays one clip per section through a hidden <audio>
  element, measures the clip's real duration and gives each word a slice of it
  proportional to its spoken length - the highlight follows the audio exactly,
  and pause/seek/chapters become real audio operations. Clips cache per
  session, the next section generates while the current plays, and a voice
  change disposes the element and its blobs. Speed applies live (setRate)
  without dropping the cache.

podcast-test stubs /api/tts with a real silent WAV and pins the path: the
voice list, the request body, exactly one playing element, and the highlight
riding currentTime. 43 checks. tests/lib.mjs launches Chromium with the
autoplay policy relaxed (a real device has the listener's gesture).
Version 0.7.6, SW v98.
2026-10-16 16:34:46 +00:00

139 lines
4.2 KiB
JavaScript

/**
* Freezmo service worker (production only).
*
* Strategy, deliberately conservative:
* - API routes are never cached and never intercepted.
* - Navigations: network first, falling back to the cached shell, then /offline.
* - /_next/static + icons: stale-while-revalidate (fast, self-healing).
* - Everything else: straight to the network.
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v98";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
const SHELL_ASSETS = [
"/",
"/offline",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/apple-touch-icon.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
await Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(new Request(url, { cache: "reload" }))));
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names.filter((name) => !name.startsWith(CACHE_VERSION)).map((name) => caches.delete(name)),
);
await self.clients.claim();
})(),
);
});
function isCacheableAsset(url) {
return (
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname === "/manifest.webmanifest" ||
/\.(?:css|js|png|jpg|jpeg|svg|webp|woff2?)$/.test(url.pathname)
);
}
/* ------------------------------------------------------------------ */
/* Push reminders (opt-in; only active when VAPID is configured) */
/* ------------------------------------------------------------------ */
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = {};
}
event.waitUntil(
self.registration.showNotification(data.title || "Freezmo", {
body: data.body || "",
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
data: { url: data.url || "/" },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ("focus" in client) {
client.navigate(url).catch(() => {});
return client.focus();
}
}
return self.clients.openWindow(url);
}),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Never touch the API: keys, notes and generated decks must always be live.
if (url.pathname.startsWith("/api/")) return;
if (request.mode === "navigate") {
event.respondWith(
(async () => {
try {
const response = await fetch(request);
// Only a real page becomes the offline shell: caching a 404 (a typo
// in the URL, a dead link) would serve the error page to every
// later offline navigation.
if (response.ok) {
const cache = await caches.open(SHELL_CACHE);
cache.put("/", response.clone()).catch(() => {});
}
return response;
} catch {
const cache = await caches.open(SHELL_CACHE);
return (await cache.match("/")) ?? (await cache.match("/offline")) ?? Response.error();
}
})(),
);
return;
}
if (isCacheableAsset(url)) {
event.respondWith(
(async () => {
const cache = await caches.open(ASSET_CACHE);
const cached = await cache.match(request);
const network = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone()).catch(() => {});
return response;
})
.catch(() => cached ?? Response.error());
return cached ?? network;
})(),
);
}
});