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:
1 parent
b1cf0f8fab
commit
63ea40d5a8
16 files changed
+640
-68
No files matched your search
@@ -0,0 +1,36 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAudioAblage } from './audio-ablage.ts';
|
||||
|
||||
const wav = (n: number): Buffer => Buffer.alloc(44 + n);
|
||||
|
||||
test('abgelegter Ton ist unter seiner ID abholbar', () => {
|
||||
const a = createAudioAblage();
|
||||
a.ablegen('x', wav(1));
|
||||
assert.equal(a.holen('x')?.length, 45);
|
||||
assert.equal(a.holen('y'), undefined);
|
||||
});
|
||||
|
||||
test('laeuft sie voll, faellt das Aelteste heraus', () => {
|
||||
const a = createAudioAblage({ maximal: 2 });
|
||||
a.ablegen('1', wav(1));
|
||||
a.ablegen('2', wav(2));
|
||||
a.ablegen('3', wav(3));
|
||||
assert.equal(a.holen('1'), undefined);
|
||||
assert.ok(a.holen('2'));
|
||||
assert.ok(a.holen('3'));
|
||||
});
|
||||
|
||||
test('abgelaufener Ton ist weg', async () => {
|
||||
const a = createAudioAblage({ haltbarMs: 5 });
|
||||
a.ablegen('x', wav(1));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(a.holen('x'), undefined);
|
||||
});
|
||||
|
||||
test('leeren wirft alles weg - fuer den Panikschalter', () => {
|
||||
const a = createAudioAblage();
|
||||
a.ablegen('x', wav(1));
|
||||
a.leeren();
|
||||
assert.equal(a.holen('x'), undefined);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Fertig gesprochene Nachrichten, bis die Browser-Quelle sie abholt.
|
||||
//
|
||||
// Warum nicht direkt im Ereignis: eine WAV von 200 Zeichen hat gut 500 KB.
|
||||
// Der Ereignisstrom geht an alle Browser-Sources, auch an Chat und
|
||||
// Statusleiste - die sollen das nicht mitschleppen. Also geht nur die ID
|
||||
// hinaus, und `/tts` holt sich den Ton einzeln.
|
||||
//
|
||||
// Bewusst im Arbeitsspeicher und kurzlebig: niemand braucht eine vorgelesene
|
||||
// Nachricht von gestern, und fremder Text soll nicht auf der Platte liegen.
|
||||
|
||||
export type AudioAblage = {
|
||||
ablegen: (id: string, wav: Buffer) => void;
|
||||
holen: (id: string) => Buffer | undefined;
|
||||
leeren: () => void;
|
||||
};
|
||||
|
||||
export function createAudioAblage(opts: { maximal?: number; haltbarMs?: number } = {}): AudioAblage {
|
||||
const maximal = opts.maximal ?? 20;
|
||||
const haltbarMs = opts.haltbarMs ?? 10 * 60_000;
|
||||
// Map haelt die Einfuegereihenfolge; das Aelteste steht vorn.
|
||||
const ablage = new Map<string, { wav: Buffer; seit: number }>();
|
||||
|
||||
const aufraeumen = (jetzt: number): void => {
|
||||
for (const [id, e] of ablage) if (jetzt - e.seit > haltbarMs) ablage.delete(id);
|
||||
while (ablage.size > maximal) {
|
||||
const aeltestes = ablage.keys().next().value;
|
||||
if (aeltestes === undefined) break;
|
||||
ablage.delete(aeltestes);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ablegen(id, wav) {
|
||||
ablage.set(id, { wav, seit: Date.now() });
|
||||
aufraeumen(Date.now());
|
||||
},
|
||||
holen(id) {
|
||||
aufraeumen(Date.now());
|
||||
return ablage.get(id)?.wav;
|
||||
},
|
||||
leeren: () => ablage.clear(),
|
||||
};
|
||||
}
|
||||
+55
-9
@@ -32,9 +32,11 @@ import { readSecrets } from '#twitch/secrets.ts';
|
||||
import { createEventSub } from '#twitch/eventsub.ts';
|
||||
import { createIrc, type UserNotice } from '#twitch/irc.ts';
|
||||
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||
import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
|
||||
import {
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
||||
} from './rewards.ts';
|
||||
import { createAudioAblage } from './audio-ablage.ts';
|
||||
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||
import { createSpikeDetector } from '../../ingest/src/spike.ts';
|
||||
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
|
||||
@@ -94,24 +96,63 @@ export async function runHub(): Promise<void> {
|
||||
const warteschlange = createWarteschlange();
|
||||
const drossel = createDrossel();
|
||||
|
||||
/** Punkte zurueckgeben. Ohne den Scope nicht moeglich - dann nur melden. */
|
||||
const erstatten = async (rewardId: string, redemptionId: string): Promise<void> => {
|
||||
/**
|
||||
* Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
|
||||
* (CANCELED). Geht nur bei Belohnungen, die das Toolkit selbst angelegt hat
|
||||
* (`abdulive rewards:anlegen`) - sonst lehnt Twitch ab, und es bleibt bei
|
||||
* einer Warnung.
|
||||
*/
|
||||
const einloesungSetzen = async (
|
||||
rewardId: string, redemptionId: string, status: 'FULFILLED' | 'CANCELED',
|
||||
): Promise<void> => {
|
||||
const was = status === 'CANCELED' ? 'Punkte nicht erstattet' : 'Einloesung nicht abgeschlossen';
|
||||
if (!state.broadcasterId) return;
|
||||
if (!helix.hasScope('channel:manage:redemptions')) {
|
||||
log.warn('Punkte nicht erstattet: dem verbundenen Konto fehlt ' +
|
||||
log.warn(`${was}: dem verbundenen Konto fehlt ` +
|
||||
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, 'CANCELED');
|
||||
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, status);
|
||||
} catch (err) {
|
||||
log.warn(`Punkte nicht erstattet: ${(err as Error).message}`);
|
||||
log.warn(`${was}: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
const erstatten = (rewardId: string, redemptionId: string): Promise<void> =>
|
||||
einloesungSetzen(rewardId, redemptionId, 'CANCELED');
|
||||
|
||||
const vorlesen = (id: string, name: string, text: string): void => {
|
||||
bus.publish({ kind: 'tts', id, name, text });
|
||||
// ---- Vorlesen. Die Stimme entsteht hier im Hub (Piper), nicht im Browser:
|
||||
// dort haengt sie am Rechner, der streamt, und kam in OBS amerikanisch.
|
||||
const stimme = {
|
||||
model: stimmenPfad(cfg.rewards.tts.stimme),
|
||||
...(typeof cfg.rewards.tts.sprecher === 'number' ? { speaker: cfg.rewards.tts.sprecher } : {}),
|
||||
};
|
||||
const audio = createAudioAblage();
|
||||
const stimmeFehlt = piperFehlt(stimme);
|
||||
if (stimmeFehlt.length) {
|
||||
log.warn(`Vorlesen ohne Stimme - es fehlt: ${stimmeFehlt.join(', ')}. ` +
|
||||
'Einrichten mit scripts/install-piper.sh. Bis dahin wird nur angezeigt.');
|
||||
}
|
||||
|
||||
const vorlesen = async (
|
||||
id: string, name: string, text: string,
|
||||
/** Fehlt beim Testknopf - dann gibt es bei Twitch nichts abzuschliessen. */
|
||||
einloesung?: { rewardId: string; redemptionId: string },
|
||||
): Promise<void> => {
|
||||
let mitTon = false;
|
||||
try {
|
||||
audio.ablegen(id, await sprechen(text, stimme));
|
||||
mitTon = true;
|
||||
} catch (err) {
|
||||
// Lieber stumm anzeigen als gar nicht: wer Punkte ausgegeben hat, soll
|
||||
// seine Nachricht wenigstens im Bild sehen.
|
||||
log.warn(`Nicht gesprochen, nur angezeigt: ${(err as Error).message}`);
|
||||
}
|
||||
bus.publish({ kind: 'tts', id, name, text, audio: mitTon });
|
||||
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
|
||||
// Auch ohne Ton abgeschlossen: angezeigt wurde es, und eine Einloesung,
|
||||
// die ewig in Twitchs Warteschlange haengt, raeumt niemand auf.
|
||||
if (einloesung) void einloesungSetzen(einloesung.rewardId, einloesung.redemptionId, 'FULFILLED');
|
||||
};
|
||||
|
||||
const behandleEinloesung = (roh: Record<string, unknown>): void => {
|
||||
@@ -149,10 +190,11 @@ export async function runHub(): Promise<void> {
|
||||
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
|
||||
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
|
||||
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
|
||||
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
|
||||
break;
|
||||
|
||||
case 'vorlesen':
|
||||
vorlesen(ulid(), e.userName, ent.text);
|
||||
void vorlesen(ulid(), e.userName, ent.text, { rewardId: e.rewardId, redemptionId: e.id });
|
||||
break;
|
||||
|
||||
case 'warteschlange':
|
||||
@@ -279,6 +321,7 @@ export async function runHub(): Promise<void> {
|
||||
goLive: announcer.lastResult() ?? 'noch nichts',
|
||||
overlayClients: bus.clients(),
|
||||
ttsWartend: warteschlange.liste().length,
|
||||
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
|
||||
spikeArmed: spikes.isArmed(),
|
||||
note: state.chatAnonymous
|
||||
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
||||
@@ -291,7 +334,7 @@ export async function runHub(): Promise<void> {
|
||||
freigeben: (id) => {
|
||||
const w = warteschlange.entnehmen(id);
|
||||
if (!w) return false;
|
||||
vorlesen(w.id, w.name, w.text);
|
||||
void vorlesen(w.id, w.name, w.text, { rewardId: w.rewardId, redemptionId: w.redemptionId });
|
||||
return true;
|
||||
},
|
||||
ablehnen: (id) => {
|
||||
@@ -307,8 +350,11 @@ export async function runHub(): Promise<void> {
|
||||
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
|
||||
return alle.length;
|
||||
},
|
||||
probe: (name, text) => { void vorlesen(ulid(), name, text); },
|
||||
audio: (id) => audio.holen(id),
|
||||
still: () => {
|
||||
bus.publish({ kind: 'still' });
|
||||
audio.leeren();
|
||||
const alle = warteschlange.leeren();
|
||||
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
||||
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
|
||||
|
||||
@@ -4,10 +4,36 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden, planFuer,
|
||||
type Einloesung, type RewardsConfig,
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden, fuerFilter, planFuer,
|
||||
wortfilterTreffer, type Einloesung, type RewardsConfig,
|
||||
} from './rewards.ts';
|
||||
|
||||
// ---- Wortfilter. Vorgelesen wird ohne Freigabe, also muss er halten.
|
||||
|
||||
const LISTE = ['hitler', 'nazi'];
|
||||
|
||||
test('Wortfilter: Leetspeak, Punkte und gezogene Buchstaben helfen nicht', () => {
|
||||
for (const t of ['H1tl3r war super', 'h.i.t.l.e.r', 'H I T L E R', 'hiiiitlerrr', 'HİTLER']) {
|
||||
assert.equal(wortfilterTreffer(t, LISTE), 'hitler', t);
|
||||
}
|
||||
});
|
||||
|
||||
test('Wortfilter: kurze Eintraege nur am Wortanfang, sonst trifft es Harmloses', () => {
|
||||
assert.equal(wortfilterTreffer('ich kann azubi werden', LISTE), undefined);
|
||||
assert.equal(wortfilterTreffer('Gymnasium', LISTE), undefined);
|
||||
assert.equal(wortfilterTreffer('alles Nazis hier', LISTE), 'nazi');
|
||||
assert.equal(wortfilterTreffer('N4Z1', LISTE), 'nazi');
|
||||
});
|
||||
|
||||
test('Wortfilter: normaler Text geht durch', () => {
|
||||
assert.equal(wortfilterTreffer('Grüße an Abdu, geiler Stream!', LISTE), undefined);
|
||||
});
|
||||
|
||||
test('fuerFilter: Umlaute und ß werden zu dem, was man hoert', () => {
|
||||
assert.equal(fuerFilter('Größe', false), 'grose');
|
||||
assert.equal(fuerFilter('a-b c', true), 'a b c');
|
||||
});
|
||||
|
||||
const CFG: RewardsConfig = {
|
||||
enabled: true,
|
||||
belohnungen: [
|
||||
|
||||
+45
-3
@@ -105,9 +105,7 @@ export function entscheiden(
|
||||
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
|
||||
}
|
||||
|
||||
const klein = text.toLowerCase();
|
||||
const treffer = cfg.tts.blockliste.find((w) => w && klein.includes(w.toLowerCase()));
|
||||
if (treffer) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
||||
if (wortfilterTreffer(text, cfg.tts.blockliste)) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
||||
|
||||
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
|
||||
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
|
||||
@@ -117,6 +115,50 @@ export function entscheiden(
|
||||
return freigabe ? { art: 'warteschlange', text } : { art: 'vorlesen', text };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Wortfilter
|
||||
|
||||
const LEET: Record<string, string> = {
|
||||
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b',
|
||||
'@': 'a', '$': 's', '!': 'i', '|': 'i',
|
||||
};
|
||||
|
||||
/**
|
||||
* Text auf das bringen, was man hoert: klein, ohne Akzente, Leetspeak
|
||||
* zurueckuebersetzt, Buchstabenketten auf einen gekuerzt. Wer "N 3 G G 3 R"
|
||||
* oder "h.i.t.l.e.r" schreibt, will am Filter vorbei - vorgelesen klingt es
|
||||
* trotzdem wie das Original.
|
||||
*
|
||||
* `mitLuecken` laesst Wortgrenzen als Leerzeichen stehen, sonst wird alles
|
||||
* zusammengezogen.
|
||||
*/
|
||||
export function fuerFilter(text: string, mitLuecken: boolean): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.normalize('NFKD').replace(/\p{M}/gu, '')
|
||||
.replace(/[0134578@$!|]/g, (c) => LEET[c] ?? c)
|
||||
.replace(/[^a-z]+/g, mitLuecken ? ' ' : '')
|
||||
.replace(/([a-z])\1+/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trifft der Text ein Wort der Blockliste?
|
||||
*
|
||||
* Lange Eintraege (ab 5 Buchstaben) werden im zusammengezogenen Text
|
||||
* gesucht, damit Punkte und Leerzeichen dazwischen nicht helfen. Kurze nur
|
||||
* am Wortanfang: zusammengezogen stuende "nazi" sonst auch in "kann azubi".
|
||||
*/
|
||||
export function wortfilterTreffer(text: string, blockliste: string[]): string | undefined {
|
||||
const dicht = fuerFilter(text, false);
|
||||
const woerter = fuerFilter(text, true).split(' ');
|
||||
return blockliste.find((eintrag) => {
|
||||
const e = fuerFilter(eintrag, false);
|
||||
if (!e) return false;
|
||||
return e.length >= 5 ? dicht.includes(e) : woerter.some((w) => w.startsWith(e));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warteschlange
|
||||
|
||||
export type Wartend = {
|
||||
|
||||
Reference in new issue
Block a user