Server-seitige TTS-Sprachausgabe (Thorsten via Piper)

- Neuer /api/speak Endpunkt: Text → Piper TTS → WAV-Audio
- Frontend speak() ruft jetzt Server-Endpunkt statt Web Speech API
- Browser-TTS nur als Fallback wenn Server nicht erreichbar
- 🔊 Button prominenter: BSN-blauer Hintergrund + sichtbarer Rand
- Fix: SVG-Icons auf Beitrags-Detailseite statt Emojis
- Fix: Datum bei Text-Only-Karten unten rechts (top:auto)
This commit is contained in:
Hermes Agent
2026-06-17 19:52:07 +02:00
parent fc07c991cc
commit eba3757572
+77 -11
View File
@@ -1881,7 +1881,7 @@ def public_frontend():
.story-preview { display:none; } .story-preview { display:none; }
.text-only .story-preview { display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; text-overflow:ellipsis; font-size:0.8rem; line-height:1.4; color:#444; margin-bottom:0.35rem; } .text-only .story-preview { display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; text-overflow:ellipsis; font-size:0.8rem; line-height:1.4; color:#444; margin-bottom:0.35rem; }
.text-only .card-content { position:relative; padding:0.55rem 0.85rem 0.65rem; } .text-only .card-content { position:relative; padding:0.55rem 0.85rem 0.65rem; }
.text-only .story-time-overlay { position:absolute; top:0.45rem; right:0.6rem; left:auto; z-index:5; } .text-only .story-time-overlay { position:absolute; top:auto; bottom:0.45rem; right:0.6rem; left:auto; z-index:5; }
/* Frontend filter bar */ /* Frontend filter bar */
.frontend-filter { max-width:1100px; margin:1rem auto 0; padding:0 1.5rem; display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; } .frontend-filter { max-width:1100px; margin:1rem auto 0; padding:0 1.5rem; display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; }
@@ -2059,7 +2059,7 @@ def public_frontend():
<div class="chat-body" id="chatBody"> <div class="chat-body" id="chatBody">
<div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;"> <div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;">
<div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div> <div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div>
<button onclick="(function(t){var u=new SpeechSynthesisUtterance(t);u.lang='de-DE';u.rate=0.95;var v=speechSynthesis.getVoices();var d=v.find(function(x){return x.lang.startsWith('de')});if(d)u.voice=d;speechSynthesis.cancel();speechSynthesis.speak(u)})('Hey! Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!')" title="Vorlesen" style="background:none;border:none;cursor:pointer;font-size:0.85rem;opacity:0.5;padding:0 0 0.15rem 0;transition:opacity 0.15s;flex-shrink:0;" onmouseenter="this.style.opacity='1'" onmouseleave="this.style.opacity='0.5'">🔊</button> <button onclick="(function(t){var u=new SpeechSynthesisUtterance(t);u.lang='de-DE';u.rate=0.95;var v=speechSynthesis.getVoices();var th=v.find(function(x){return x.name==='Thorsten'});if(th)u.voice=th;else{var d=v.find(function(x){return x.lang.startsWith('de')});if(d)u.voice=d}speechSynthesis.cancel();speechSynthesis.speak(u)})('Hey! Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!')" title="Vorlesen" style="background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;" onmouseenter="this.style.background='rgba(32,34,138,0.18)';this.style.borderColor='rgba(32,34,138,0.4)'" onmouseleave="this.style.background='rgba(32,34,138,0.08)';this.style.borderColor='rgba(32,34,138,0.2)'">🔊</button>
</div> </div>
</div> </div>
<div class="chat-footer"> <div class="chat-footer">
@@ -2278,9 +2278,9 @@ def public_frontend():
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.innerHTML = '🔊'; btn.innerHTML = '🔊';
btn.title = 'Vorlesen'; btn.title = 'Vorlesen';
btn.style.cssText = 'background:none;border:none;cursor:pointer;font-size:0.85rem;opacity:0.5;padding:0 0 0.15rem 0;transition:opacity 0.15s;flex-shrink:0;'; btn.style.cssText = 'background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;';
btn.onmouseenter = () => btn.style.opacity = '1'; btn.onmouseenter = () => { btn.style.background = 'rgba(32,34,138,0.18)'; btn.style.borderColor = 'rgba(32,34,138,0.4)'; };
btn.onmouseleave = () => btn.style.opacity = '0.5'; btn.onmouseleave = () => { btn.style.background = 'rgba(32,34,138,0.08)'; btn.style.borderColor = 'rgba(32,34,138,0.2)'; };
btn.onclick = () => speak(text); btn.onclick = () => speak(text);
wrapper.appendChild(btn); wrapper.appendChild(btn);
} }
@@ -2290,18 +2290,32 @@ def public_frontend():
return div; return div;
} }
function speak(text) { async function speak(text) {
if (!window.speechSynthesis) return; try {
const r = await fetch('/api/speak', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({text:text})
});
if (!r.ok) throw new Error('TTS failed');
const blob = await r.blob();
const audio = new Audio(URL.createObjectURL(blob));
audio.play();
} catch(e) {
// Fallback: browser TTS
if (window.speechSynthesis) {
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text); const u = new SpeechSynthesisUtterance(text);
u.lang = 'de-DE'; u.lang = 'de-DE';
u.rate = 0.95; u.rate = 0.95;
// Prefer German voice
const voices = speechSynthesis.getVoices(); const voices = speechSynthesis.getVoices();
const de = voices.find(v => v.lang.startsWith('de')); const thorsten = voices.find(v => v.name === 'Thorsten');
if (de) u.voice = de; if (thorsten) u.voice = thorsten;
else { const de = voices.find(v => v.lang.startsWith('de')); if (de) u.voice = de; }
speechSynthesis.speak(u); speechSynthesis.speak(u);
} }
}
}
// Preload voices // Preload voices
if (window.speechSynthesis) speechSynthesis.getVoices(); if (window.speechSynthesis) speechSynthesis.getVoices();
@@ -2485,6 +2499,51 @@ def api_chat():
return jsonify({'reply': reply}) return jsonify({'reply': reply})
@app.route('/api/speak', methods=['POST'])
def api_speak():
"""Generate TTS audio via Piper (Thorsten voice) and return as WAV."""
import subprocess
import tempfile
data = request.get_json(force=True, silent=True) or {}
text = (data.get('text') or '').strip()
if not text or len(text) > 2000:
return jsonify({'error': 'Text required, max 2000 chars'}), 400
model = '/home/hermes/.local/share/piper-voices/de_DE-thorsten-medium.onnx'
if not os.path.exists(model):
model = '/home/hermes/.hermes/cache/piper-voices/de_DE-thorsten-medium.onnx'
if not os.path.exists(model):
return jsonify({'error': 'Thorsten voice not found'}), 500
with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmp:
tmp_path = tmp.name
try:
env = os.environ.copy()
env['LD_LIBRARY_PATH'] = '/home/hermes/.local/lib:' + env.get('LD_LIBRARY_PATH', '')
env['ESPEAK_DATA_PATH'] = '/home/hermes/.local/share/espeak-ng-data'
result = subprocess.run(
['/home/hermes/.local/bin/piper', '--model', model, '--output_file', tmp_path],
input=text.encode('utf-8'),
capture_output=True,
timeout=30,
env=env,
)
if result.returncode != 0:
return jsonify({'error': 'TTS generation failed'}), 500
from flask import send_file
response = send_file(tmp_path, mimetype='audio/wav', as_attachment=False)
response.headers['X-Content-Type-Options'] = 'nosniff'
return response
except Exception as e:
return jsonify({'error': f'TTS failed: {str(e)}'}), 500
finally:
# Clean up temp file after response is sent
import threading
threading.Timer(5.0, lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None).start()
@app.route('/api/submit', methods=['POST']) @app.route('/api/submit', methods=['POST'])
def api_submit(): def api_submit():
"""Public submission endpoint — text + optional media upload.""" """Public submission endpoint — text + optional media upload."""
@@ -2638,7 +2697,14 @@ def public_detail(sub_id: int):
except Exception: except Exception:
pass pass
type_emoji = {"text": "💬", "image": "🖼️", "audio": "🎤", "video": "🎬", "document": "📎"}.get(row["type"], "📌") _detail_svg_icons = {
"text": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:6px;"><rect x="1.5" y="2" width="13" height="10" rx="2" stroke="#20228a" stroke-width="1.5"/><path d="M11 11l2 3M5 11l-2 3" stroke="#20228a" stroke-width="1.5" stroke-linecap="round"/></svg>',
"image": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:6px;"><rect x="1.5" y="1.5" width="13" height="13" rx="2" stroke="#20228a" stroke-width="1.5"/><circle cx="5" cy="5.5" r="1.5" fill="#20228a"/><path d="M1.5 11.5l3.5-3 2.5 2 3-3.5 4 4.5" stroke="#20228a" stroke-width="1.5" stroke-linejoin="round"/></svg>',
"audio": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:6px;"><rect x="1.5" y="4" width="3.5" height="8" rx="1" fill="#20228a"/><path d="M5 6.5l4-4v11l-4-4" stroke="#20228a" stroke-width="1.5" stroke-linejoin="round"/><path d="M9.5 4.5c1.5.8 1.5 4.2 0 5" stroke="#20228a" stroke-width="1.5" stroke-linecap="round"/></svg>',
"video": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:6px;"><rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="#20228a" stroke-width="1.5"/><path d="M6 5l4 3-4 3V5z" fill="#20228a"/></svg>',
"document": '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style="vertical-align:middle;margin-right:6px;"><path d="M3 1.5h6l4 4v9a1 1 0 01-1 1H3a1 1 0 01-1-1v-12a1 1 0 011-1z" stroke="#20228a" stroke-width="1.5"/><path d="M9 1.5V6h4" stroke="#20228a" stroke-width="1.5"/></svg>',
}
type_emoji = _detail_svg_icons.get(row["type"], _detail_svg_icons["document"])
name = (row["publish_name"] or row["sender_name"] or "Anonym").strip() name = (row["publish_name"] or row["sender_name"] or "Anonym").strip()
if not name or name.lower() in ("unknown", "anonym"): if not name or name.lower() in ("unknown", "anonym"):
name = "Anonym" name = "Anonym"