Add server-side native German TTS endpoint with MP3 disk caching
This commit is contained in:
1 parent
ffe25e782a
commit
e8aef17029
2 files changed
+121
-5
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user