"use strict";
const VOICE_STORAGE_KEY = "mhrn.speech.voices.v2";
const RATE_STORAGE_KEY = "mhrn.speech.rate.v2";
const NATURAL_NAMES = {
de: ["katja", "conrad", "amala", "bernd", "christa", "detlef", "elke", "fiona"],
en: ["jenny", "aria", "guy", "ryan", "sonia", "davis", "amber", "ashley", "emma", "brian", "christopher", "eric", "jacob", "nancy", "sara"],
fr: ["henri", "charlotte", "remi", "josephine"],
es: ["elvira", "dalia", "alvaro", "marisol"],
it: ["diego", "isabella", "imelda", "cosimo"],
};
const NATURAL_KEYWORDS = /natural|neural|online|premium|enhanced|wavenet|studio|ultra/i;
let activeSession = null;
function supported() {
return typeof window !== "undefined" && "speechSynthesis" in window && "SpeechSynthesisUtterance" in window;
}
export function cleanSpeechText(value) {
return String(value || "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/https?:\/\/\S+/g, "")
.replace(/[#*_`~]+/g, "")
.replace(/\s+/g, " ")
.trim();
}
function splitSpeechText(value, limit = 260) {
const text = cleanSpeechText(value);
if (!text) return [];
const sentences = text.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [text];
const chunks = [];
let current = "";
for (const sentence of sentences) {
const candidate = `${current} ${sentence}`.trim();
if (current && candidate.length > limit) {
chunks.push(current);
current = sentence.trim();
} else {
current = candidate;
}
}
if (current) chunks.push(current);
return chunks.flatMap((chunk) => {
if (chunk.length <= limit) return [chunk];
const words = chunk.split(/\s+/);
const result = [];
let part = "";
for (const word of words) {
const candidate = `${part} ${word}`.trim();
if (part && candidate.length > limit) {
result.push(part);
part = word;
} else {
part = candidate;
}
}
if (part) result.push(part);
return result;
});
}
function baseLang(lang) {
const l = String(lang || "de-DE").toLowerCase();
if (l.startsWith("en")) return "en";
if (l.startsWith("fr")) return "fr";
if (l.startsWith("es")) return "es";
if (l.startsWith("it")) return "it";
return "de";
}
export function detectSpeechLanguage(value, fallback = "de-DE") {
const text = ` ${cleanSpeechText(value).toLowerCase()} `;
if (!text.trim()) return fallback;
const english = [" the ", " and ", " of ", " to ", " in ", " is ", " are ", " with ", " this ", " that ", " from ", " results ", " discussion ", " abstract "];
const german = [" der ", " die ", " das ", " und ", " von ", " zu ", " ist ", " sind ", " mit ", " diese ", " dass ", " aus ", " ergebnisse ", " diskussion ", " zusammenfassung "];
const score = (terms) => terms.reduce((sum, term) => sum + (text.includes(term) ? 1 : 0), 0);
const englishScore = score(english);
const germanScore = score(german);
if (englishScore > germanScore + 1) return "en-US";
if (germanScore > englishScore) return "de-DE";
return fallback;
}
function getStoredPreferences() {
try {
return JSON.parse(localStorage.getItem(VOICE_STORAGE_KEY) || "{}") || {};
} catch (_) {
return {};
}
}
function saveStoredPreference(lang, name) {
const preferences = getStoredPreferences();
preferences[baseLang(lang)] = name || "";
try { localStorage.setItem(VOICE_STORAGE_KEY, JSON.stringify(preferences)); } catch (_) {}
}
function getStoredRate() {
const value = Number(localStorage.getItem(RATE_STORAGE_KEY));
return Number.isFinite(value) && value >= 0.6 && value <= 1.5 ? value : 0.96;
}
function saveStoredRate(value) {
try { localStorage.setItem(RATE_STORAGE_KEY, String(value)); } catch (_) {}
}
export function listSpeechVoices(lang = "") {
if (!supported()) return [];
const voices = window.speechSynthesis.getVoices() || [];
if (!lang) return [...voices];
const wanted = baseLang(lang);
return voices.filter((voice) => baseLang(voice.lang) === wanted);
}
function voiceScore(voice, lang) {
const wanted = baseLang(lang);
const name = String(voice.name || "").toLowerCase();
const voiceLang = String(voice.lang || "").toLowerCase();
let score = 0;
if (baseLang(voiceLang) === wanted) score += 100;
if (voiceLang === String(lang || "").toLowerCase()) score += 25;
if (NATURAL_KEYWORDS.test(name)) score += 60;
if (NATURAL_NAMES[wanted]?.some((candidate) => name.includes(candidate))) score += 55;
if (/microsoft/.test(name)) score += 22;
if (/google/.test(name)) score += 18;
if (/apple|siri/.test(name)) score += 15;
if (/amazon|alexa/.test(name)) score += 14;
if (voice.default) score += 8;
if (voice.localService) score += 4;
return score;
}
export function chooseSpeechVoice(lang = "de-DE", preferredName = "") {
const voices = listSpeechVoices(lang);
if (!voices.length) return null;
const stored = preferredName || getStoredPreferences()[baseLang(lang)] || "";
if (stored) {
const exact = voices.find((voice) => voice.name === stored);
if (exact) return exact;
}
return [...voices].sort((left, right) => voiceScore(right, lang) - voiceScore(left, lang))[0] || null;
}
function normalizeSegments(value, fallbackLang = "de-DE") {
const source = Array.isArray(value) ? value : [{ text: value }];
const normalized = [];
source.forEach((segment, segmentIndex) => {
const text = typeof segment === "string" ? segment : segment?.text;
const clean = cleanSpeechText(text);
if (!clean) return;
const lang = segment?.lang || detectSpeechLanguage(clean, fallbackLang);
splitSpeechText(clean).forEach((chunk, chunkIndex) => normalized.push({
text: chunk,
lang,
element: segment?.element || null,
segmentIndex,
chunkIndex,
metadata: segment?.metadata || null,
}));
});
return normalized;
}
export function stopSpeech() {
if (!supported()) return;
window.speechSynthesis.cancel();
if (activeSession) activeSession.onState("stopped");
activeSession = null;
}
export function pauseSpeech() {
if (!supported() || !activeSession) return false;
window.speechSynthesis.pause();
activeSession.onState("paused");
return true;
}
export function resumeSpeech() {
if (!supported() || !activeSession) return false;
window.speechSynthesis.resume();
activeSession.onState("speaking");
return true;
}
export function speakSegments(value, {
onState = () => {},
onSegment = () => {},
lang = "de-DE",
rate = getStoredRate(),
preferredVoices = {},
} = {}) {
if (!supported()) {
onState("unsupported");
return false;
}
const segments = normalizeSegments(value, lang);
if (!segments.length) {
onState("empty");
return false;
}
stopSpeech();
const session = { segments, index: 0, onState, onSegment, rate };
activeSession = session;
const speakNext = () => {
if (activeSession !== session) return;
if (session.index >= session.segments.length) {
activeSession = null;
session.onState("done");
return;
}
const segment = session.segments[session.index++];
const utterance = new SpeechSynthesisUtterance(segment.text);
utterance.lang = segment.lang;
utterance.rate = rate;
utterance.pitch = 1;
const voice = chooseSpeechVoice(segment.lang, preferredVoices[baseLang(segment.lang)] || "");
if (voice) utterance.voice = voice;
utterance.onstart = () => {
session.onSegment(segment, session.index - 1, session.segments.length);
session.onState("speaking");
};
utterance.onend = speakNext;
utterance.onerror = (event) => {
if (event.error === "interrupted" || event.error === "canceled") return;
if (activeSession === session) {
activeSession = null;
session.onState("error");
}
};
window.speechSynthesis.speak(utterance);
};
speakNext();
return true;
}
export function speakText(value, { onState = () => {}, lang = "de-DE", rate = getStoredRate(), onSegment = () => {} } = {}) {
return speakSegments([{ text: value, lang }], { onState, onSegment, lang, rate });
}
function voiceOptionMarkup(lang, selectedName) {
const voices = listSpeechVoices(lang).sort((left, right) => voiceScore(right, lang) - voiceScore(left, lang));
if (!voices.length) return '';
return voices.map((voice) => {
const quality = NATURAL_KEYWORDS.test(voice.name) || NATURAL_NAMES[baseLang(lang)]?.some((name) => voice.name.toLowerCase().includes(name)) ? " ★" : "";
return ``;
}).join("");
}
function escapeText(value) {
const node = document.createElement("span");
node.textContent = String(value || "");
return node.innerHTML;
}
function escapeAttribute(value) {
return escapeText(value).replace(/"/g, """);
}
export function createSpeechControls(mount, getText, {
label = "Text vorlesen",
includeStart = true,
getSegments = null,
onSegment = () => {},
showVoiceOptions = false,
lang = "de-DE",
} = {}) {
const controls = document.createElement("div");
controls.className = "speech-reader-controls";
controls.setAttribute("role", "group");
controls.setAttribute("aria-label", label);
controls.innerHTML = `${includeStart ? `` : ""}
${showVoiceOptions ? `Stimmen
★ bevorzugt natürliche/neuronale Systemstimmen. Unter Windows werden insbesondere Katja/Conrad/Amala/Bernd (DE), Jenny/Aria/Guy/Ryan/Davis/Amber/Emma/Brian (EN), Henri/Charlotte (FR), Elvira/Alvaro (ES) und Diego/Isabella (IT) priorisiert.