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.
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The provider-voice preference: which TTS provider and voice the listener
|
|
* picked for the podcast player ("system" keeps the platform's own voices).
|
|
* One blob in localStorage, read through a tiny accessor.
|
|
*/
|
|
|
|
import { PROVIDERS, TTS_PROFILES, ttsProfile, type ProviderInfo } from "./providers";
|
|
|
|
const STORAGE_KEY = "freezmo.podcast-natural.v1";
|
|
|
|
export type NaturalPref = {
|
|
source: "system" | "natural";
|
|
provider: string;
|
|
voice: string | null;
|
|
model: string | null;
|
|
};
|
|
|
|
const FALLBACK: NaturalPref = { source: "system", provider: "", voice: null, model: null };
|
|
|
|
export function readNaturalPref(): NaturalPref {
|
|
if (typeof window === "undefined") return FALLBACK;
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return FALLBACK;
|
|
const parsed = JSON.parse(raw) as Partial<NaturalPref> | null;
|
|
if (!parsed || typeof parsed !== "object") return FALLBACK;
|
|
return {
|
|
source: parsed.source === "natural" ? "natural" : "system",
|
|
provider: typeof parsed.provider === "string" ? parsed.provider : "",
|
|
voice: typeof parsed.voice === "string" ? parsed.voice : null,
|
|
model: typeof parsed.model === "string" ? parsed.model : null,
|
|
};
|
|
} catch {
|
|
return FALLBACK;
|
|
}
|
|
}
|
|
|
|
export function writeNaturalPref(next: Partial<NaturalPref>): NaturalPref {
|
|
const merged = { ...readNaturalPref(), ...next };
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
|
|
} catch {
|
|
/* private mode */
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/** Providers with a TTS profile that the listener actually has a key for. */
|
|
export function ttsProvidersWithKeys(keys: Record<string, string | undefined>): string[] {
|
|
return Object.keys(TTS_PROFILES).filter((id) => Boolean(keys[id]));
|
|
}
|
|
|
|
/** The provider to use when the listener has not chosen one. */
|
|
export function defaultTtsProvider(keys: Record<string, string | undefined>, generation?: string): string {
|
|
const available = ttsProvidersWithKeys(keys);
|
|
if (available.length === 0) return "";
|
|
if (generation && available.includes(generation)) return generation;
|
|
/* Prefer OpenAI when both are configured: its voices are the better ones. */
|
|
if (available.includes("openai")) return "openai";
|
|
return available[0];
|
|
}
|
|
|
|
export function voiceForProvider(providerId: string, wanted: string | null): string {
|
|
const profile = ttsProfile(providerId);
|
|
if (!profile) return "";
|
|
if (wanted && profile.voices.some((voice) => voice.id === wanted)) return wanted;
|
|
return profile.voices.find((voice) => voice.male)?.id ?? profile.voices[0]?.id ?? "";
|
|
}
|
|
|
|
/** Provider labels for the picker (id → label). */
|
|
export function ttsProviderLabel(id: string): string {
|
|
return PROVIDERS.find((provider: ProviderInfo) => provider.id === id)?.label ?? id;
|
|
}
|
|
|
|
export { TTS_PROFILES };
|