freezmo/lib/podcast-natural.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

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