Vorlesen: Piper im Hub statt Sprachausgabe im Browser

In OBS' Browser-Quelle kam `speechSynthesis` amerikanisch heraus: der
eingebaute Browser sah keine deutsche Stimme, und die Seite nahm die erste
beliebige. Welche Stimmen es gibt, haengt am Rechner, der streamt.

Jetzt spricht der Hub mit Piper (lokal im LXC, 200 Zeichen in 0,7 s). Die
WAV liegt kurz im Speicher, `/tts` holt sie unter /tts/audio/<id> mit dem
Overlay-Schluessel und spielt sie ueber <audio> ab - das laeuft sicher ueber
OBS' Tonspur. Der Browser springt nur noch mit einer deutschen Stimme ein,
sonst wird nur angezeigt. Stimme und Sprecher stehen in rewards.tts.

Dazu:

- `abdulive rewards:anlegen`: Belohnung ueber die API anlegen. Nur die App,
  die sie angelegt hat, darf Einloesungen abschliessen oder erstatten.
- Einloesungen werden nach der Aktion als FULFILLED markiert, statt in
  Twitchs Warteschlange liegen zu bleiben.
- Wortfilter normalisiert vorher: Leetspeak, Punkte dazwischen, gezogene
  Buchstaben, Akzente. Kurze Eintraege nur am Wortanfang.
- Der Testknopf "Vorlesen" geht durch Piper und prueft so die echte Stimme.
- scripts/install-piper.sh richtet Piper und Stimme im LXC ein.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZeasyAlexandClaude Opus 5.5 committed 2026-09-23 22:58:36 +02:00
1 parent b1cf0f8fab
commit 63ea40d5a8
16 files changed
+640 -68

No files matched your search

+70 -43
View File
@@ -33,8 +33,8 @@
}
.was { font-size: 22px; line-height: 1.35; }
/* Ein Hinweis nur fuer den Fall, dass die Browser-Quelle keine Sprachausgabe
* kann. Im Stream soll das auffallen, nicht stillschweigend fehlen. */
/* Ein Hinweis fuer den Fall, dass eine Nachricht ohne Ton bleibt. Im Stream
* soll das auffallen, nicht stillschweigend fehlen. */
#fehlt {
position: fixed;
left: 16px; top: 16px;
@@ -49,8 +49,8 @@
</head>
<body>
<div id="fehlt">
Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut).
Vorgelesen wird nichts.
Kein Ton: der Hub konnte nicht sprechen, und hier gibt es keine deutsche
Stimme. Die Nachricht wird nur angezeigt.
</div>
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></div>
@@ -65,29 +65,31 @@
var sprache = Overlay.param('sprache', 'de-DE');
var tempo = Overlay.param('tempo', 1);
var lautstaerke = Overlay.param('laut', 1);
var lautstaerke = Math.max(0, Math.min(1, Overlay.param('laut', 1)));
var schluessel = Overlay.param('key', '');
var fehltEl = document.getElementById('fehlt');
var warteschlange = [];
var laeuft = false;
/* Was gerade klingt, damit der Panikschalter es abbrechen kann. */
var aktuell = null;
/* Ob die Browser-Quelle ueberhaupt sprechen kann, weiss man erst zur
* Laufzeit: OBS' eingebauter Browser wird teils ohne Sprachausgabe gebaut.
* Ohne diese Pruefung bliebe es im Stream einfach still, und niemand wuesste
* warum. */
var kannSprechen = typeof window.speechSynthesis !== 'undefined' &&
typeof window.SpeechSynthesisUtterance !== 'undefined';
if (!kannSprechen) {
document.getElementById('fehlt').style.display = 'block';
console.error('[tts] Diese Browser-Quelle hat keine Sprachausgabe.');
}
/* Stimmen werden asynchron geladen. Beim ersten Aufruf ist die Liste oft
* noch leer - deshalb bei jedem Sprechen neu nachsehen statt einmal am
* Anfang. */
function stimme() {
if (!kannSprechen) return null;
/* Gesprochen wird im Hub (Piper), hier wird nur abgespielt. Das hat zwei
* Gruende: die eingebaute Sprachausgabe von OBS' Browser sah keine deutsche
* Stimme und las amerikanisch, und ein <audio> laeuft sicher ueber OBS'
* Tonspur statt am Mixer vorbei.
*
* Die Browser-Sprachausgabe bleibt nur als Notnagel, wenn der Hub keinen
* Ton liefern konnte - und auch dann nur mit einer deutschen Stimme. Eine
* englische Stimme, die deutschen Text radebrecht, ist schlimmer als Stille. */
function deutscheStimme() {
if (typeof window.speechSynthesis === 'undefined' ||
typeof window.SpeechSynthesisUtterance === 'undefined') return null;
var alle = window.speechSynthesis.getVoices() || [];
var passend = alle.filter(function (v) { return v.lang && v.lang.indexOf(sprache.slice(0, 2)) === 0; });
return passend[0] || alle[0] || null;
var kurz = sprache.slice(0, 2).toLowerCase();
for (var i = 0; i < alle.length; i++) {
if (alle[i].lang && alle[i].lang.toLowerCase().indexOf(kurz) === 0) return alle[i];
}
return null;
}
function zeige(name, text) {
@@ -95,47 +97,71 @@
wasEl.textContent = text;
kasten.classList.add('an');
}
function verstecke() { kasten.classList.remove('an'); }
function verstecke() { kasten.classList.remove('an'); fehltEl.style.display = 'none'; }
/* Geschaetzte Sprechdauer, als Notbremse und fuer die stumme Anzeige. */
function dauerMs(text) { return 3000 + text.length * 90; }
function naechste() {
var n = warteschlange.shift();
aktuell = null;
if (!n) { laeuft = false; verstecke(); return; }
laeuft = true;
zeige(n.name, n.text);
if (!kannSprechen) {
// Wenigstens anzeigen, und nach der geschaetzten Lesedauer weiter.
setTimeout(naechste, Math.min(15000, 2000 + n.text.length * 60));
return;
}
var rede = new SpeechSynthesisUtterance(n.text);
rede.lang = sprache;
rede.rate = tempo;
rede.volume = lautstaerke;
var v = stimme();
if (v) rede.voice = v;
var weiter = false;
var gehWeiter = function () {
if (weiter) return;
weiter = true;
setTimeout(naechste, 400);
};
// Notbremse: haengt die Wiedergabe, steht die Warteschlange sonst fuer
// den Rest des Streams. Grosszuegig bemessen.
setTimeout(gehWeiter, 8000 + dauerMs(n.text) * 2);
if (n.audio) {
var ton = new Audio('/tts/audio/' + encodeURIComponent(n.id) +
(schluessel ? '?key=' + encodeURIComponent(schluessel) : ''));
ton.volume = lautstaerke;
ton.onended = gehWeiter;
ton.onerror = function () {
console.error('[tts] Ton nicht abspielbar', ton.error && ton.error.code);
gehWeiter();
};
aktuell = { stopp: function () { try { ton.pause(); } catch (e) { /* egal */ } } };
var p = ton.play();
// Ein normaler Browser blockiert Ton ohne vorherigen Klick. OBS nicht -
// wer die Seite zum Testen im Browser offen hat, sieht es hier.
if (p && p.catch) p.catch(function (e) {
console.error('[tts] Wiedergabe verweigert', e && e.name);
gehWeiter();
});
return;
}
var stimme = deutscheStimme();
if (!stimme) {
console.error('[tts] Kein Ton vom Hub und keine deutsche Stimme - nur Anzeige.');
fehltEl.style.display = 'block';
setTimeout(gehWeiter, dauerMs(n.text));
return;
}
var rede = new SpeechSynthesisUtterance(n.text);
rede.lang = stimme.lang;
rede.voice = stimme;
rede.rate = tempo;
rede.volume = lautstaerke;
rede.onend = gehWeiter;
rede.onerror = function (e) {
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
gehWeiter();
};
// Notbremse: bleibt die Sprachausgabe haengen, steht die Warteschlange
// sonst fuer den Rest des Streams. Grosszuegig bemessen.
setTimeout(gehWeiter, 5000 + n.text.length * 250);
aktuell = { stopp: function () { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } } };
window.speechSynthesis.speak(rede);
}
Overlay.on('tts', function (ev) {
warteschlange.push({ name: ev.name, text: ev.text });
warteschlange.push({ id: ev.id, name: ev.name, text: ev.text, audio: !!ev.audio });
if (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
if (!laeuft) naechste();
});
@@ -143,7 +169,8 @@
// Panikschalter: sofort still, Warteschlange weg.
Overlay.on('still', function () {
warteschlange.length = 0;
if (kannSprechen) { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } }
if (aktuell) aktuell.stopp();
aktuell = null;
laeuft = false;
verstecke();
});