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

@ -21,6 +21,16 @@ import type { PodcastSection } from "./schemas";
export type PodcastVoice = { section: number; word: number };
/** Tidy text for the speech engine (see the note where it is used). */
export function normaliseSpoken(raw: string): string {
return raw
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\ufeff]/g, " ")
.replace(/…/g, ", ")
.replace(/\s*—\s*/g, " — ")
.replace(/\s+/g, " ")
.trim();
}
export type PodcastSpeechEvents = {
sections: PodcastSection[];
locale: string;
@ -129,10 +139,16 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
position = { section: target.section, word };
const from = sectionTokens[word].start;
const text = section.text.slice(from);
/* Spoken text hygiene: engines click on control characters, stray
double spaces and unicode ellipses, and some pause oddly around them. */
const text = normaliseSpoken(section.text.slice(from));
const current = ++generation;
try {
window.speechSynthesis.cancel();
/* Only cancel when something is actually pending: an idle cancel makes
several engines pop. */
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
window.speechSynthesis.cancel();
}
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = events.locale;
utterance.rate = events.rate;
@ -200,7 +216,12 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
events.onEnd();
return;
}
speakFrom({ section: target.section + 1, word: 0 });
/* A short beat between parts: speaking into the engine's tail is
what makes the boundary pop on several platforms. */
window.setTimeout(() => {
if (generation !== current) return;
speakFrom({ section: target.section + 1, word: 0 });
}, 140);
};
utterance.onerror = (event) => {