freezmo/lib/providers.ts
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

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";