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:
Martin Dvořák 2026-10-16 16:34:46 +00:00
commit 8e1cd0855d
13 changed files with 997 additions and 63 deletions

View file

@ -291,6 +291,43 @@ export async function generatePodcast(
return { podcast: ready, meta: {} };
}
/* ------------------------------------------------------------------ */
/* Natural podcast voices (provider text-to-speech) */
/* ------------------------------------------------------------------ */
/**
* One section of the script, spoken by the picked provider. The key rides in
* the headers exactly like every other model call; the audio comes back as a
* blob URL the player hands to an <audio> element.
*/
export async function synthesizeSpeech(
input: { text: string; voice?: string; model?: string; providerId: string },
options: { signal?: AbortSignal } = {},
): Promise<{ url: string; type: string }> {
const response = await fetch("/api/tts", {
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeaders(),
...providerHeaders(input.providerId),
...localeHeader(),
},
body: JSON.stringify({ text: input.text, voice: input.voice, model: input.model }),
signal: options.signal,
}).catch(() => {
throw new ApiError(t("api.unreachable"), "network");
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as { error?: string; kind?: string } | null;
throw new ApiError(body?.error ?? t("api.httpError", { status: response.status }), body?.kind);
}
const blob = await response.blob();
if (blob.size === 0) throw new ApiError(t("api.noAudio"), "bad_response");
return { url: URL.createObjectURL(blob), type: blob.type };
}
/* ------------------------------------------------------------------ */
/* Make my homework */
/* ------------------------------------------------------------------ */