Podcast player: calmer line-following scroll, and a voice you can pick
"The scrolling works kinda weirdly" + "maybe a less robotic voice, preferably male". Scroll: the transcript now follows by LINE, not by word - words on the same line never move the page (that was the jitter), a line only scrolls once it leaves a comfortable band, small corrections are instant rather than animated, and a wheel/touch/drag on the transcript pauses the following for four seconds instead of fighting the reader. Chapter jumps and word taps re-centre at once. Voice: lib/podcast-voices ranks what the platform installed (natural / neural / online names up, espeak / SAM / compact down, a male name preferred) and the player picks the best one automatically; a new picker in the chrome lists everything in the episode's language, applies it live and remembers the choice per language. A note points at the system settings when only robotic voices exist. The engine takes the chosen voice instead of guessing. podcast-test: 34 checks now (voice ranking, filtered picker, persisted pick, same-line silence, line following, manual-scroll suspension). Version 0.7.4, SW v96.
This commit is contained in:
parent
d95c5c7151
commit
4ec986da12
8 changed files with 684 additions and 46 deletions
|
|
@ -302,6 +302,11 @@ const en: Record<string, string> = {
|
|||
"podcast.pause": "Pause the episode",
|
||||
"podcast.restart": "Start from the beginning",
|
||||
"podcast.speedAria": "Playback speed",
|
||||
"podcast.voiceAria": "Voice",
|
||||
"podcast.voiceAuto": "Automatic (best available)",
|
||||
"podcast.voiceLocal": "installed",
|
||||
"podcast.voiceNote": "Voices come from your browser and system — pick the one that sounds best to you.",
|
||||
"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.",
|
||||
"podcast.noVoice": "This browser has no speech voice installed, so the transcript cannot be read aloud here. You can still read along.",
|
||||
"podcast.loadingEpisode": "Loading the episode",
|
||||
|
|
@ -1369,6 +1374,11 @@ const cs: Record<string, string> = {
|
|||
"podcast.pause": "Pozastavit epizodu",
|
||||
"podcast.restart": "Začít od začátku",
|
||||
"podcast.speedAria": "Rychlost přehrávání",
|
||||
"podcast.voiceAria": "Hlas",
|
||||
"podcast.voiceAuto": "Automaticky (nejlepší dostupný)",
|
||||
"podcast.voiceLocal": "nainstalovaný",
|
||||
"podcast.voiceNote": "Hlasy dodává prohlížeč a systém — vyber si ten, co zní nejlíp.",
|
||||
"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.",
|
||||
"podcast.noVoice": "Tento prohlížeč nemá nainstalovaný hlas, takže se přepis nedá přečíst nahlas. Číst ho ale můžeš dál.",
|
||||
"podcast.loadingEpisode": "Načítání epizody",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
* "jump to a chapter" and "resume where you left off" the same operation.
|
||||
*/
|
||||
|
||||
import { pickVoice, speechAvailable } from "./speech";
|
||||
import { speechAvailable } from "./speech";
|
||||
import type { PodcastSection } from "./schemas";
|
||||
|
||||
export type PodcastVoice = { section: number; word: number };
|
||||
|
|
@ -25,6 +25,8 @@ export type PodcastSpeechEvents = {
|
|||
sections: PodcastSection[];
|
||||
locale: string;
|
||||
rate: number;
|
||||
/** The chosen voice (lib/podcast-voices); null lets the platform decide. */
|
||||
voice?: SpeechSynthesisVoice | null;
|
||||
/** Global word position changed (word = index inside its section). */
|
||||
onWord: (position: PodcastVoice) => void;
|
||||
/** Playback actually began (after the engine accepted it). */
|
||||
|
|
@ -134,8 +136,7 @@ export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech
|
|||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = events.locale;
|
||||
utterance.rate = events.rate;
|
||||
const voice = pickVoice(events.locale);
|
||||
if (voice) utterance.voice = voice;
|
||||
if (events.voice) utterance.voice = events.voice;
|
||||
|
||||
const offsetFor = (charIndex: number) => {
|
||||
/* First token whose span ends after the reported character. */
|
||||
|
|
|
|||
231
lib/podcast-voices.ts
Normal file
231
lib/podcast-voices.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Choosing a voice for the podcast player.
|
||||
*
|
||||
* The Web Speech API ships no voices of its own: the list comes from the OS
|
||||
* and the browser, and it swings wildly between platforms — from espeak's
|
||||
* robot on a bare Linux box to Google's network voices, Microsoft's neural
|
||||
* ones and macOS's Alex. This module ranks what is installed, prefers a male
|
||||
* voice (a podcast host, per the listener's wish) among the good ones, and
|
||||
* remembers the listener's own pick per language.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "freezmo.podcast-voice.v1";
|
||||
|
||||
/** Names that usually mean "a human actually recorded this". */
|
||||
const QUALITY_HINTS = ["natural", "neural", "online", "premium", "enhanced", "siri", "google", "microsoft"];
|
||||
/** Engines that always sound like a 1980s answering machine. */
|
||||
const ROBOTIC_HINTS = ["espeak", "sam", "pico", "festival", "flite", "compact", "robot"];
|
||||
|
||||
/**
|
||||
* Male first names that appear in speech-voice catalogues. Not exhaustive —
|
||||
* it only has to beat "pick the first voice for the language".
|
||||
*/
|
||||
const MALE_NAMES = [
|
||||
"male", // "Google UK English Male"
|
||||
"alex",
|
||||
"daniel",
|
||||
"fred",
|
||||
"thomas",
|
||||
"oliver",
|
||||
"david",
|
||||
"mark",
|
||||
"james",
|
||||
"ryan",
|
||||
"guy",
|
||||
"george",
|
||||
"pavel",
|
||||
"jakub",
|
||||
"marek",
|
||||
"ondrej",
|
||||
"vojtech",
|
||||
"jirka",
|
||||
"petr",
|
||||
"tomas",
|
||||
"martin",
|
||||
"lukas",
|
||||
"jan",
|
||||
"antonin",
|
||||
"josef",
|
||||
"jorge",
|
||||
"diego",
|
||||
"carlos",
|
||||
"juan",
|
||||
"pablo",
|
||||
"luca",
|
||||
"matteo",
|
||||
"marco",
|
||||
"pierre",
|
||||
"henri",
|
||||
"klaus",
|
||||
"hans",
|
||||
"stefan",
|
||||
"felix",
|
||||
"magnus",
|
||||
"erik",
|
||||
"yuri",
|
||||
"dmitri",
|
||||
"ivan",
|
||||
"xander",
|
||||
"zhihao",
|
||||
"takumi",
|
||||
"haruto",
|
||||
"minho",
|
||||
"ji-hoon",
|
||||
"wei",
|
||||
"liang",
|
||||
"mikhail",
|
||||
];
|
||||
|
||||
const FEMALE_NAMES = [
|
||||
"female", // "Google UK English Female"
|
||||
"samantha",
|
||||
"karen",
|
||||
"moira",
|
||||
"tessa",
|
||||
"victoria",
|
||||
"allison",
|
||||
"ava",
|
||||
"susan",
|
||||
"zira",
|
||||
"siri",
|
||||
"zuzana",
|
||||
"iveta",
|
||||
"eliska",
|
||||
"tana",
|
||||
"katerina",
|
||||
"monika",
|
||||
"lucie",
|
||||
"pavla",
|
||||
"anna",
|
||||
"maria",
|
||||
"laura",
|
||||
"julia",
|
||||
"carmen",
|
||||
"paulina",
|
||||
"zofia",
|
||||
"elsa",
|
||||
"katja",
|
||||
"ingrid",
|
||||
"freya",
|
||||
"yelena",
|
||||
"svetlana",
|
||||
"olga",
|
||||
"yuki",
|
||||
"sakura",
|
||||
"mei",
|
||||
"ling",
|
||||
"xiaoxiao",
|
||||
];
|
||||
|
||||
type StoredVoices = Record<string, string>;
|
||||
|
||||
let stored: StoredVoices | null = null;
|
||||
|
||||
function loadStored(): StoredVoices {
|
||||
if (stored) return stored;
|
||||
stored = {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
for (const [locale, uri] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof uri === "string") stored[locale] = uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
const languageOf = (voice: SpeechSynthesisVoice) => voice.lang.toLowerCase().replace("_", "-");
|
||||
|
||||
/** Voices usable for `locale` (exact region first, then the language). */
|
||||
export function voicesFor(locale: string, voices?: SpeechSynthesisVoice[]): SpeechSynthesisVoice[] {
|
||||
const all = voices ?? platformVoices();
|
||||
if (all.length === 0) return [];
|
||||
const wanted = locale.toLowerCase().replace("_", "-");
|
||||
const prefix = wanted.split("-")[0];
|
||||
const matching = all.filter((voice) => {
|
||||
const lang = languageOf(voice);
|
||||
return lang === wanted || lang.startsWith(`${prefix}-`) || lang === prefix;
|
||||
});
|
||||
/* Some platforms tag voices oddly (e.g. "en-GB" for a US voice): never show
|
||||
* an empty list when voices exist at all. */
|
||||
return matching.length > 0 ? matching : all;
|
||||
}
|
||||
|
||||
/** The platform's voice list (empty offline/unsupported). */
|
||||
export function platformVoices(): SpeechSynthesisVoice[] {
|
||||
try {
|
||||
if (typeof window === "undefined" || !("speechSynthesis" in window)) return [];
|
||||
return window.speechSynthesis.getVoices();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** A larger score wins. `male` tilts the ranking towards male names. */
|
||||
export function scoreVoice(voice: SpeechSynthesisVoice, locale: string, male: boolean): number {
|
||||
const name = voice.name.toLowerCase();
|
||||
const lang = languageOf(voice);
|
||||
const wanted = locale.toLowerCase().replace("_", "-");
|
||||
let score = 0;
|
||||
|
||||
if (lang === wanted) score += 6;
|
||||
else if (lang.split("-")[0] === wanted.split("-")[0]) score += 3;
|
||||
|
||||
if (QUALITY_HINTS.some((hint) => name.includes(hint))) score += 10;
|
||||
if (ROBOTIC_HINTS.some((hint) => name.includes(hint))) score -= 12;
|
||||
|
||||
if (MALE_NAMES.some((entry) => name.includes(entry))) score += male ? 8 : 1;
|
||||
if (FEMALE_NAMES.some((entry) => name.includes(entry))) score += male ? 1 : 4;
|
||||
|
||||
/* Network voices are usually the neural ones; local ones are the fallback. */
|
||||
if (!voice.localService) score += 3;
|
||||
if (voice.default) score += 1;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/** The best voice for the episode: the listener's stored pick, else the best
|
||||
* male, else the best of whatever there is. */
|
||||
export function bestVoice(locale: string, voices?: SpeechSynthesisVoice[]): SpeechSynthesisVoice | null {
|
||||
const list = voicesFor(locale, voices);
|
||||
if (list.length === 0) return null;
|
||||
const chosen = getStoredVoice(locale);
|
||||
if (chosen) {
|
||||
const match = list.find((voice) => voice.voiceURI === chosen);
|
||||
if (match) return match;
|
||||
}
|
||||
const ranked = [...list].sort((a, b) => scoreVoice(b, locale, true) - scoreVoice(a, locale, true));
|
||||
return ranked[0] ?? null;
|
||||
}
|
||||
|
||||
export function getStoredVoice(locale: string): string | null {
|
||||
return loadStored()[locale.toLowerCase().replace("_", "-")] ?? null;
|
||||
}
|
||||
|
||||
export function setStoredVoice(locale: string, voiceURI: string): void {
|
||||
const key = locale.toLowerCase().replace("_", "-");
|
||||
const next = { ...loadStored(), [key]: voiceURI };
|
||||
stored = next;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}
|
||||
|
||||
/** True when nothing but the platform's robotic voices exist for `locale`. */
|
||||
export function onlyRoboticVoices(locale: string, voices?: SpeechSynthesisVoice[]): boolean {
|
||||
const list = voicesFor(locale, voices);
|
||||
if (list.length === 0) return false;
|
||||
return list.every((voice) => ROBOTIC_HINTS.some((hint) => voice.name.toLowerCase().includes(hint)));
|
||||
}
|
||||
|
||||
export const MALE_VOICE_HINTS = MALE_NAMES;
|
||||
Loading…
Add table
Add a link
Reference in a new issue