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:
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user