Add server-side native German TTS endpoint with MP3 disk caching

This commit is contained in:
chrisf committed 2026-10-21 04:38:19 +00:00
1 parent ffe25e782a
commit e8aef17029
2 files changed
+121 -5

No files matched your search

+35 -5
View File
@@ -287,21 +287,51 @@ class WortKraftApp {
if (tooltip) tooltip.classList.remove("visible");
}
// === AUDIO PRONUNCIATION (Web Speech API de-DE) ===
speak(text, rate = 0.92) {
// === AUDIO PRONUNCIATION (Server Native German Neural TTS + Browser Fallback) ===
speak(text, rate = 0.96) {
if (!text) return;
// Stop any currently playing audio
if (this.currentAudio) {
try {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
} catch (_e) {}
this.currentAudio = null;
}
if ("speechSynthesis" in window) {
window.speechSynthesis.cancel();
}
// Primary: Stream authentic native German (de-DE) audio from backend /api/tts
if (window.location.protocol.startsWith("http")) {
const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`);
audio.playbackRate = rate;
if ("preservesPitch" in audio) audio.preservesPitch = true;
this.currentAudio = audio;
audio.play().catch(err => {
console.warn("Server TTS playback error, falling back to browser TTS:", err);
this.speakBrowserFallback(text, rate);
});
return;
}
this.speakBrowserFallback(text, rate);
}
speakBrowserFallback(text, rate = 0.92) {
if (!("speechSynthesis" in window)) {
this.showToast("Audio wird von diesem Browser leider nicht unterstützt.");
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "de-DE";
utterance.rate = rate;
const voices = window.speechSynthesis.getVoices();
const germanVoice =
voices.find(v => v.lang.startsWith("de") && (v.name.includes("Natural") || v.name.includes("Google") || v.name.includes("Anna") || v.name.includes("Katja"))) ||
voices.find(v => v.lang.startsWith("de"));
voices.find(v => v.lang.toLowerCase().startsWith("de") && (v.name.includes("Natural") || v.name.includes("Google") || v.name.includes("Anna") || v.name.includes("Katja") || v.name.includes("Hedda") || v.name.includes("Stefan"))) ||
voices.find(v => v.lang.toLowerCase().startsWith("de"));
if (germanVoice) {
utterance.voice = germanVoice;
}
+86
View File
@@ -2,6 +2,7 @@ import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
@@ -12,10 +13,14 @@ const PORT = process.env.PORT || 80;
const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data_store');
const STATE_FILE = path.join(DATA_DIR, 'wortkraft_state.json');
const TRANSLATE_CACHE_FILE = path.join(DATA_DIR, 'translations_cache.json');
const TTS_CACHE_DIR = path.join(DATA_DIR, 'tts_cache');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
if (!fs.existsSync(TTS_CACHE_DIR)) {
fs.mkdirSync(TTS_CACHE_DIR, { recursive: true });
}
function readJson(filePath, fallback) {
try {
@@ -39,6 +44,41 @@ function writeJson(filePath, obj) {
}
}
// Split longer German text into sentence/phrase chunks <= 180 chars for TTS
function splitTextForTts(text, maxLen = 180) {
const cleaned = text.replace(/\s+/g, ' ').trim();
if (cleaned.length <= maxLen) return [cleaned];
const sentences = cleaned.split(/(?<=[.!?…;:])\s+/);
const chunks = [];
let current = '';
for (const sentence of sentences) {
if ((current + ' ' + sentence).trim().length <= maxLen) {
current = (current + ' ' + sentence).trim();
} else {
if (current) chunks.push(current);
if (sentence.length <= maxLen) {
current = sentence;
} else {
// Split very long sentence by commas or words
const words = sentence.split(' ');
current = '';
for (const w of words) {
if ((current + ' ' + w).trim().length <= maxLen) {
current = (current + ' ' + w).trim();
} else {
if (current) chunks.push(current);
current = w;
}
}
}
}
}
if (current) chunks.push(current);
return chunks;
}
app.use(cors());
app.use(express.json({ limit: '10mb' }));
@@ -52,6 +92,52 @@ app.get('/api/health', (_req, res) => {
});
});
// Native German (de-DE) TTS Audio Endpoint with disk caching
app.get('/api/tts', async (req, res) => {
const text = String(req.query.text || '').trim();
if (!text) {
return res.status(400).json({ error: 'Missing text parameter' });
}
const hash = crypto.createHash('sha1').update(text).digest('hex');
const cachedFile = path.join(TTS_CACHE_DIR, `${hash}.mp3`);
if (fs.existsSync(cachedFile)) {
res.setHeader('Content-Type', 'audio/mpeg');
res.setHeader('Cache-Control', 'public, max-age=604800');
return fs.createReadStream(cachedFile).pipe(res);
}
try {
const chunks = splitTextForTts(text, 180);
const buffers = [];
for (const chunk of chunks) {
const ttsUrl = `https://translate.google.com/translate_tts?ie=UTF-8&client=tw-ob&tl=de&q=${encodeURIComponent(chunk)}`;
const response = await fetch(ttsUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
});
if (!response.ok) {
throw new Error(`TTS upstream returned ${response.status}`);
}
const arrBuf = await response.arrayBuffer();
buffers.push(Buffer.from(arrBuf));
}
const combined = Buffer.concat(buffers);
fs.writeFileSync(cachedFile, combined);
res.setHeader('Content-Type', 'audio/mpeg');
res.setHeader('Cache-Control', 'public, max-age=604800');
return res.send(combined);
} catch (err) {
console.error('[WORTKRAFT] TTS error:', err.message);
return res.status(502).json({ error: 'TTS generation failed' });
}
});
// Get persisted learning state (SRS deck, streak, custom words & stories)
app.get('/api/state', (_req, res) => {
const state = readJson(STATE_FILE, null);