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

+22 -1
View File
@@ -89,6 +89,10 @@ export type OverlayOptions = {
ablehnen: (id: string) => boolean;
alleAblehnen: () => number;
still: () => void;
/** Spricht einen Text sofort, ohne Warteschlange. Fuer den Testknopf. */
probe: (name: string, text: string) => void;
/** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */
audio: (id: string) => Buffer | undefined;
};
};
@@ -162,13 +166,23 @@ async function handle(
// ---- Die Browser-Sources und ihr Datenstrom.
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
if (page || p === '/events') {
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
if (page || p === '/events' || audioId) {
if (!zugang.darfOverlay(req, url)) {
return send(res, 403, 'text/plain; charset=utf-8',
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
'Sie steht fertig auf der Uebersichtsseite.');
}
if (p === '/events') return sse(req, res, bus);
if (audioId) {
const wav = opts.tts?.audio(audioId);
if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
res.writeHead(200, {
'content-type': 'audio/wav', 'content-length': wav.length, 'cache-control': 'no-store',
});
res.end(wav);
return;
}
return sendFile(res, page!.file);
}
@@ -220,6 +234,13 @@ async function handle(
const chat = zustand(bus).chat;
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
// Vorlesen geht durch den Hub, damit der Knopf die echte Stimme prueft
// und nicht nur die Anzeige.
if (ev.kind === 'tts' && opts.tts) {
opts.tts.probe(ev.name, ev.text);
log.info('Testereignis ausgeloest: tts (mit Stimme)');
return antwort(`${knopf.label} ausgelöst.`);
}
bus.publish(ev);
log.info(`Testereignis ausgeloest: ${knopf.art}`);
return antwort(`${knopf.label} ausgelöst.`);