Podcast player: say why it cannot speak, with a way out
"The podcasts don't even work on my Linux setup on Firefox." Firefox on Linux speaks through speech-dispatcher; without it speechSynthesis is present but empty and silent - getVoices() is empty, speak() does nothing, no events - so the player looked broken. - pressing play with zero installed voices (or a start that never arrives within 1.7s) raises a banner naming the cause, with the Linux fix (sudo apt install speech-dispatcher) and the iOS one where it applies; - the banner offers "Use the natural voice" when a provider key exists (it switches the source, keeps playing and remembers it), "Add a provider key" when not (opens Settings) and a link into the picker; - natural-voice errors (refused autoplay, provider refusal) show in that same banner instead of hiding inside the picker. podcast-test fakes a present-but-empty speechSynthesis and pins the banner, the Linux line and the one-tap switch. 48 checks. Version 0.7.8, SW v100.
This commit is contained in:
parent
5d82612e2a
commit
08a145b087
6 changed files with 147 additions and 8 deletions
14
README.md
14
README.md
|
|
@ -1492,6 +1492,20 @@ Things I chose while building, with the reasoning:
|
|||
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
|
||||
scene-heat rework unchanged.
|
||||
|
||||
106. **When the browser has no speech engine at all.** "The podcasts don't even work on my Linux
|
||||
setup on Firefox": Firefox on Linux speaks through speech-dispatcher, and without it
|
||||
`speechSynthesis` is *present* (so `speechAvailable()` says yes) but empty and silent —
|
||||
`getVoices()` returns nothing and `speak()` does nothing, with no events. The player used to
|
||||
sit there looking broken. Now the failure is named and has a way out: pressing play with zero
|
||||
installed voices (or a start that never arrives within 1.7 s) raises a banner that says what is
|
||||
missing — *"On Firefox/Linux install speech-dispatcher (sudo apt install speech-dispatcher) and
|
||||
reopen this page"* — with **Use the natural voice** right there when a provider key exists (it
|
||||
switches the source, starts playing and remembers the choice), **Add a provider key** when not
|
||||
(it opens Settings), and a link into the voice picker. Natural-voice errors (a refused
|
||||
autoplay, a provider refusal) are surfaced in the same place instead of hiding inside the
|
||||
picker. The suite fakes a present-but-empty `speechSynthesis` and pins the banner, the Linux
|
||||
line and the one-tap switch (48 checks).
|
||||
|
||||
105. **The play button that only worked once.** "It only plays once, and when you pause it you can't
|
||||
unpause." WebKit's quirk, faithfully: `speechSynthesis.cancel()` leaves the synthesis **paused**,
|
||||
and every later `speak()` is queued into that paused state — silently. Three fixes in the
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { createPodcastAudio, type PodcastAudio } from "@/lib/podcast-audio";
|
||||
import {
|
||||
|
|
@ -32,6 +33,7 @@ import {
|
|||
import { speechAvailable, speechLocale, stopSpeaking } from "@/lib/speech";
|
||||
import type { Podcast } from "@/lib/schemas";
|
||||
import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
||||
import { openSettings } from "@/lib/ui-events";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronLeftIcon,
|
||||
|
|
@ -63,6 +65,9 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
|||
const [playing, setPlaying] = useState(false);
|
||||
const [position, setPosition] = useState<PodcastVoice | null>(null);
|
||||
const [unsupported, setUnsupported] = useState(false);
|
||||
/** The system voice never started (no engine installed) — Firefox/Linux,
|
||||
* a bare Windows install: the banner explains and offers the way out. */
|
||||
const [voiceSilent, setVoiceSilent] = useState(false);
|
||||
const [voiceOpen, setVoiceOpen] = useState(false);
|
||||
/** "system" = the platform's voices; "natural" = the provider's TTS. */
|
||||
const [voiceSource, setVoiceSource] = useState<"system" | "natural">(() => readNaturalPref().source);
|
||||
|
|
@ -430,13 +435,28 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
|||
persist();
|
||||
return;
|
||||
}
|
||||
if (!naturalActive && availableVoices.length === 0) {
|
||||
/* Nothing to speak with: say so instead of doing nothing. */
|
||||
setVoiceSilent(true);
|
||||
return;
|
||||
}
|
||||
/* Other readers (a card's read-aloud) yield to the episode. */
|
||||
stopSpeaking();
|
||||
harness()?.play(position ?? { section: 0, word: 0 });
|
||||
/* The system engine can also be *present* and still do nothing (no voice
|
||||
data behind it). If nothing starts, the banner takes over. */
|
||||
if (!naturalActive) {
|
||||
const attempt = Date.now();
|
||||
window.setTimeout(() => {
|
||||
if (Date.now() - attempt < 1500) return;
|
||||
if (!playingRef.current) setVoiceSilent(true);
|
||||
}, 1700);
|
||||
}
|
||||
};
|
||||
|
||||
const seek = (next: PodcastVoice) => {
|
||||
if (!canPlay) return;
|
||||
setVoiceSilent(false);
|
||||
latest.current = next;
|
||||
setPosition(next);
|
||||
/* A seek is an instruction to show that place: follow it at once. */
|
||||
|
|
@ -568,13 +588,59 @@ export function PodcastPlayer({ episode }: { episode: Podcast }) {
|
|||
className="no-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-40 pt-24"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
{(!supported || unsupported) && !naturalActive ? (
|
||||
<p className="rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
{t("podcast.noVoice")}
|
||||
{isAppleMobile() ? ` ${t("podcast.voiceIos")}` : ""}
|
||||
{naturalError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-2xl border border-rose-300/50 bg-rose-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-rose-800 dark:border-rose-400/25 dark:bg-rose-400/10 dark:text-rose-200"
|
||||
>
|
||||
{naturalError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{(!naturalActive && (!supported || unsupported || voiceSilent)) || (naturalActive && !natural) ? (
|
||||
<div className="space-y-2.5 rounded-2xl border border-amber-300/50 bg-amber-50/70 px-3.5 py-3 text-[12.5px] leading-relaxed text-amber-800 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200">
|
||||
<p>
|
||||
{naturalActive && !natural
|
||||
? t("podcast.voiceNeedsKey")
|
||||
: `${
|
||||
voiceSilent || unsupported
|
||||
? t("podcast.voiceSilent")
|
||||
: t("podcast.noVoice")
|
||||
}${isAppleMobile() ? ` ${t("podcast.voiceIos")}` : ""}`}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{ttsProviderIds.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setVoiceSilent(false);
|
||||
setUnsupported(false);
|
||||
setNaturalError(null);
|
||||
setVoiceSource("natural");
|
||||
writeNaturalPref({ source: "natural" });
|
||||
}}
|
||||
>
|
||||
{t("podcast.voiceUseNatural")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => openSettings()}>
|
||||
{t("podcast.voiceOpenProviders")}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setVoiceSilent(false);
|
||||
setVoiceOpen(true);
|
||||
}}
|
||||
className="text-[12.5px] font-semibold underline decoration-dotted"
|
||||
>
|
||||
{t("podcast.voiceAria")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card space-y-2 p-5">
|
||||
<h1 className="text-[22px] font-extrabold leading-tight tracking-[-0.01em] text-[var(--text)]">
|
||||
{episode.title}
|
||||
|
|
|
|||
|
|
@ -314,6 +314,9 @@ const en: Record<string, string> = {
|
|||
"podcast.voiceNaturalNote": "Generated by your own provider key — the words are timed to the audio, so the highlight still follows the voice.",
|
||||
"podcast.voiceNeedsKey": "Add an OpenAI or Groq key in Settings → Providers to use natural voices.",
|
||||
"podcast.voiceNoSystem": "This device reports no system voices. The natural voice works anywhere — or add a voice in your system settings.",
|
||||
"podcast.voiceSilent": "The system voice did not start — this browser has no speech engine behind it. On Firefox/Linux install speech-dispatcher (sudo apt install speech-dispatcher) and reopen this page.",
|
||||
"podcast.voiceUseNatural": "Use the natural voice",
|
||||
"podcast.voiceOpenProviders": "Add a provider key",
|
||||
"podcast.voiceGenerating": "Generating the voice…",
|
||||
"podcast.voiceRobotic": "Only the basic system voices are installed. For a natural host voice, add one in your system settings (Windows: Speech; macOS: Spoken Content; Chrome ships the Google voices).",
|
||||
"podcast.tapToJump": "Tap any word to jump there.",
|
||||
|
|
@ -1401,6 +1404,9 @@ const cs: Record<string, string> = {
|
|||
"podcast.voiceNaturalNote": "Generuje ho klíč tvého providera — slova jsou časovaná podle audia, takže zvýraznění drží s hlasem.",
|
||||
"podcast.voiceNeedsKey": "Pro přirozené hlasy si v Nastavení → Provideři přidej klíč OpenAI nebo Groq.",
|
||||
"podcast.voiceNoSystem": "Tohle zařízení nehlásí žádné systémové hlasy. Přirozený hlas funguje všude — nebo si hlas přidej v nastavení systému.",
|
||||
"podcast.voiceSilent": "Systémový hlas se nespustil — prohlížeč za ním nemá žádný hlasový engine. Na Firefoxu/Linuxu doinstaluj speech-dispatcher (sudo apt install speech-dispatcher) a stránku otevři znovu.",
|
||||
"podcast.voiceUseNatural": "Použít přirozený hlas",
|
||||
"podcast.voiceOpenProviders": "Přidat klíč providera",
|
||||
"podcast.voiceGenerating": "Generuji hlas…",
|
||||
"podcast.voiceRobotic": "Jsou nainstalované jen základní systémové hlasy. Pro přirozený hlas moderátora si nějaký přidej v nastavení systému (Windows: Řeč; macOS: Mluvený obsah; Chrome má hlasy Google).",
|
||||
"podcast.tapToJump": "Klikni na kterékoli slovo a přeskočíš tam.",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.7.7",
|
||||
"version": "0.7.8",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v99";
|
||||
const CACHE_VERSION = "freezmo-v100";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ Object.defineProperty(window, "speechSynthesis", {
|
|||
configurable: true,
|
||||
value: {
|
||||
speak(utterance) { window.__utterances.push(utterance); queueMicrotask(() => utterance.onstart && utterance.onstart({ name: "start", charIndex: 0 })); },
|
||||
cancel() {}, getVoices() { return []; }, addEventListener() {}, removeEventListener() {}, speaking: false, paused: false,
|
||||
cancel() {}, getVoices() { return [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }]; },
|
||||
addEventListener() {}, removeEventListener() {}, speaking: false, paused: false,
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
|
@ -160,7 +161,8 @@ Object.defineProperty(window, "speechSynthesis", {
|
|||
},
|
||||
cancel() { window.__cancelCount += 1; this._speaking = false; this._paused = true; },
|
||||
resume() { this._paused = false; },
|
||||
getVoices() { return []; },
|
||||
/* One installed voice: a working engine, which is what this stub is. */
|
||||
getVoices() { return [{ name: "Daniel", lang: "en-GB", voiceURI: "daniel", localService: true, default: true }]; },
|
||||
},
|
||||
});
|
||||
`;
|
||||
|
|
@ -383,6 +385,57 @@ try {
|
|||
await context.close();
|
||||
}
|
||||
|
||||
/* 4b2b. no speech engine behind the browser (Firefox on Linux) ------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
|
||||
/* speechSynthesis exists but is empty and silent — exactly what Firefox
|
||||
does on a Linux box without speech-dispatcher. */
|
||||
await page.addInitScript(() => {
|
||||
class FakeUtterance {
|
||||
constructor(text) { this.text = String(text); this.rate = 1; this.lang = "en-US"; }
|
||||
}
|
||||
Object.defineProperty(window, "SpeechSynthesisUtterance", { configurable: true, value: FakeUtterance });
|
||||
Object.defineProperty(window, "speechSynthesis", {
|
||||
configurable: true,
|
||||
value: {
|
||||
speak() { /* nothing at all: no engine, no events */ },
|
||||
cancel() {},
|
||||
getVoices: () => [],
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
speaking: false,
|
||||
paused: false,
|
||||
},
|
||||
});
|
||||
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: "Play the episode" }).click();
|
||||
await page.waitForTimeout(400);
|
||||
const banner = await page.getByText(/speech engine behind it/).count();
|
||||
check("no engine: the banner explains what is missing", banner === 1, String(banner));
|
||||
check(
|
||||
"no engine: it names the Linux fix",
|
||||
(await page.getByText(/speech-dispatcher/).count()) === 1,
|
||||
);
|
||||
const toNatural = page.getByRole("button", { name: "Use the natural voice" });
|
||||
check("no engine: the one-tap way out is offered", (await toNatural.count()) === 1);
|
||||
await toNatural.click();
|
||||
await page.waitForTimeout(400);
|
||||
check(
|
||||
"no engine: the switch sticks",
|
||||
(await page.evaluate(() => localStorage.getItem("freezmo.podcast-natural.v1")))?.includes("natural"),
|
||||
String(await page.evaluate(() => localStorage.getItem("freezmo.podcast-natural.v1"))),
|
||||
);
|
||||
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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue