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
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue