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.
155 lines
4.6 KiB
TypeScript
155 lines
4.6 KiB
TypeScript
/**
|
|
* Cloud model providers the user can bring their own key for. Every one of
|
|
* them speaks the OpenAI Chat Completions shape (Anthropic ships an official
|
|
* compatibility layer at the same path), so one adapter covers them all —
|
|
* plus a "custom" entry for any other OpenAI-compatible endpoint.
|
|
*
|
|
* Shared by the client (key inputs, pickers) and the server (routing): no
|
|
* secrets live here.
|
|
*/
|
|
|
|
export type ProviderInfo = {
|
|
id: string;
|
|
label: string;
|
|
/** OpenAI-compatible API base. Empty for the custom provider. */
|
|
baseUrl: string;
|
|
/** Placeholder shown in the key input. */
|
|
keyHint: string;
|
|
/** Where to create a key. */
|
|
docs: string;
|
|
/**
|
|
* Models offered before the live listing arrives (and on providers whose
|
|
* /models needs a paid tier). Free-tier ids first where they exist.
|
|
*/
|
|
models: string[];
|
|
};
|
|
|
|
export const PROVIDERS: ProviderInfo[] = [
|
|
{
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
keyHint: "sk-…",
|
|
docs: "https://platform.openai.com/api-keys",
|
|
models: ["gpt-4o-mini", "gpt-4o", "o4-mini"],
|
|
},
|
|
{
|
|
id: "anthropic",
|
|
label: "Anthropic (Claude)",
|
|
baseUrl: "https://api.anthropic.com/v1",
|
|
keyHint: "sk-ant-…",
|
|
docs: "https://console.anthropic.com/settings/keys",
|
|
models: ["claude-sonnet-4-5", "claude-haiku-4-5", "claude-3-5-haiku-latest"],
|
|
},
|
|
{
|
|
id: "deepseek",
|
|
label: "DeepSeek",
|
|
baseUrl: "https://api.deepseek.com/v1",
|
|
keyHint: "sk-…",
|
|
docs: "https://platform.deepseek.com/api_keys",
|
|
models: ["deepseek-chat", "deepseek-reasoner"],
|
|
},
|
|
{
|
|
id: "groq",
|
|
label: "Groq",
|
|
baseUrl: "https://api.groq.com/openai/v1",
|
|
keyHint: "gsk_…",
|
|
docs: "https://console.groq.com/keys",
|
|
models: ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "openai/gpt-oss-120b"],
|
|
},
|
|
{
|
|
id: "openrouter",
|
|
label: "OpenRouter",
|
|
baseUrl: "https://openrouter.ai/api/v1",
|
|
keyHint: "sk-or-…",
|
|
docs: "https://openrouter.ai/keys",
|
|
models: [
|
|
"meta-llama/llama-3.3-70b-instruct:free",
|
|
"deepseek/deepseek-chat-v3-0324:free",
|
|
"qwen/qwen3-235b-a22b:free",
|
|
],
|
|
},
|
|
{
|
|
id: "gemini",
|
|
label: "Google Gemini",
|
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
keyHint: "AIza…",
|
|
docs: "https://aistudio.google.com/app/apikey",
|
|
models: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"],
|
|
},
|
|
{
|
|
id: "custom",
|
|
label: "Custom (OpenAI-compatible)",
|
|
baseUrl: "",
|
|
keyHint: "key…",
|
|
docs: "",
|
|
models: [],
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Text-to-speech for the podcast player. Only OpenAI-compatible
|
|
* `/audio/speech` endpoints are wired (OpenAI, Groq and any local server the
|
|
* custom provider points at); the rest simply do not offer TTS. Voice ids come
|
|
* from each provider's catalogue — the male ones lead because a podcast host
|
|
* was the ask.
|
|
*/
|
|
export type TtsVoice = { id: string; label: string; male?: boolean };
|
|
|
|
export type TtsProfile = {
|
|
/** Default model for /audio/speech. */
|
|
model: string;
|
|
voices: TtsVoice[];
|
|
/** Response format to request (Groq speaks WAV, everything else MP3). */
|
|
format: "mp3" | "wav";
|
|
};
|
|
|
|
export const TTS_PROFILES: Record<string, TtsProfile> = {
|
|
openai: {
|
|
model: "gpt-4o-mini-tts",
|
|
format: "mp3",
|
|
voices: [
|
|
{ id: "onyx", label: "Onyx", male: true },
|
|
{ id: "echo", label: "Echo", male: true },
|
|
{ id: "ash", label: "Ash", male: true },
|
|
{ id: "fable", label: "Fable" },
|
|
{ id: "nova", label: "Nova" },
|
|
{ id: "coral", label: "Coral" },
|
|
{ id: "shimmer", label: "Shimmer" },
|
|
],
|
|
},
|
|
groq: {
|
|
model: "playai-tts",
|
|
format: "wav",
|
|
voices: [
|
|
{ id: "Fritz-PlayAI", label: "Fritz", male: true },
|
|
{ id: "Thunder-PlayAI", label: "Thunder", male: true },
|
|
{ id: "Mason-PlayAI", label: "Mason", male: true },
|
|
{ id: "Celeste-PlayAI", label: "Celeste" },
|
|
{ id: "Cheyenne-PlayAI", label: "Cheyenne" },
|
|
],
|
|
},
|
|
custom: {
|
|
model: "tts-1",
|
|
format: "mp3",
|
|
voices: [
|
|
{ id: "onyx", label: "Onyx", male: true },
|
|
{ id: "echo", label: "Echo", male: true },
|
|
{ id: "alloy", label: "Alloy" },
|
|
{ id: "nova", label: "Nova" },
|
|
],
|
|
},
|
|
};
|
|
|
|
export function ttsProfile(providerId: string): TtsProfile | null {
|
|
return TTS_PROFILES[providerId] ?? null;
|
|
}
|
|
|
|
export function providerInfo(id: string): ProviderInfo | undefined {
|
|
return PROVIDERS.find((provider) => provider.id === id);
|
|
}
|
|
|
|
/** Headers the browser sends so the server can reach the picked provider. */
|
|
export const PROVIDER_HEADER = "x-freezmo-provider";
|
|
export const PROVIDER_KEY_HEADER = "x-freezmo-provider-key";
|
|
export const PROVIDER_BASE_HEADER = "x-freezmo-provider-base";
|