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

@ -60,7 +60,13 @@ export function resolveChrome() {
export const CHROME = resolveChrome();
export async function launchBrowser() {
return chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] });
return chromium.launch({
executablePath: CHROME,
/* The podcast player plays generated audio through an <audio> element;
headless Chromium would refuse it without this flag (a real device has
the listener's own gesture, which is the policy's real gate). */
args: ["--no-sandbox", "--autoplay-policy=no-user-gesture-required"],
});
}
/** axe-core source, injected into the page for the contrast audit. */

View file

@ -368,6 +368,91 @@ try {
await context.close();
}
/* 4b3. natural voices: the provider TTS behind the same player ------- */
{
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
/* One second of silence as a real WAV — the player measures the clip and
times its words against it, so the header alone is not enough. */
const wav = (() => {
const sampleRate = 8000;
const samples = sampleRate;
const buffer = Buffer.alloc(44 + samples * 2);
buffer.write("RIFF", 0);
buffer.writeUInt32LE(36 + samples * 2, 4);
buffer.write("WAVE", 8);
buffer.write("fmt ", 12);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(sampleRate, 24);
buffer.writeUInt32LE(sampleRate * 2, 28);
buffer.writeUInt16LE(2, 32);
buffer.writeUInt16LE(16, 34);
buffer.write("data", 36);
buffer.writeUInt32LE(samples * 2, 40);
return buffer;
})();
const bodies = [];
await page.route("**/api/tts", async (route) => {
bodies.push(route.request().postDataJSON());
await route.fulfill({ status: 200, contentType: "audio/wav", body: wav });
});
await page.addInitScript(() => {
localStorage.setItem(
"freezmo.providers.v1",
JSON.stringify({ keys: { openai: "sk-test" }, models: {}, customBaseUrl: "", generation: null }),
);
});
await seed(page, { decks: [], theme: "dark", extra: { "freezmo.podcasts.v1": JSON.stringify([EPISODE_RECORD]) } });
await page.goto(`${APP}/podcast/pod_local`, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
await page.getByRole("button", { name: "Voice" }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Natural voice" }).click();
await page.waitForTimeout(400);
const voicesShown = await page.evaluate(() =>
[...document.querySelectorAll("button[aria-pressed]")].map((el) => el.textContent?.trim() ?? ""),
);
check(
"natural: the provider's voices are offered (male first)",
voicesShown.some((entry) => entry.includes("Onyx")) && voicesShown.some((entry) => entry.includes("male")),
JSON.stringify(voicesShown.slice(0, 5)),
);
await page.getByRole("button", { name: /Onyx/ }).click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Close" }).click();
await page.waitForTimeout(200);
await page.getByRole("button", { name: "Play the episode" }).click();
/* The stub clip is half a second of silence: check while it is playing. */
await page.waitForTimeout(320);
const first = bodies[0];
check("natural: the voice is generated through the API", bodies.length >= 1, String(bodies.length));
check(
"natural: the request carries the section text and the picked voice",
Boolean(first) && String(first.text).startsWith("Daylight.") && first.voice === "onyx",
JSON.stringify({ voice: first?.voice, head: String(first?.text).slice(0, 24) }),
);
const audioState = await page.evaluate(() => {
const elements = [...document.querySelectorAll("[data-podcast-audio]")];
const audio = elements.at(-1);
return audio ? { count: elements.length, paused: audio.paused, blob: audio.src.startsWith("blob:") } : "no-audio-element";
});
check(
"natural: one audio element plays the clip",
typeof audioState === "object" && audioState.blob && audioState.paused === false && audioState.count === 1,
JSON.stringify(audioState),
);
check("natural: the position is remembered for later", (await page.evaluate(() => localStorage.getItem("freezmo.podcast-natural.v1")))?.includes("onyx"), "");
/* The word highlight rides the clip's real clock. */
const before = await page.evaluate(() => document.querySelector("[data-word-active]")?.textContent ?? "");
await page.waitForTimeout(1200);
const after = await page.evaluate(() => document.querySelector("[data-word-active]")?.textContent ?? "");
check("natural: the highlight follows the audio", before !== "" && after !== "", JSON.stringify({ before, after }));
await context.close();
}
/* 4c. the transcript follows by line, and yields to a manual scroll --- */
{
const { context, page } = await makePage(browser, { width: 390, height: 700, deviceScaleFactor: 1, isMobile: true, hasTouch: true });