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.
This commit is contained in:
parent
9882d775e7
commit
8e1cd0855d
13 changed files with 997 additions and 63 deletions
77
lib/podcast-natural.ts
Normal file
77
lib/podcast-natural.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue