diff --git a/apps/cli/main.ts b/apps/cli/main.ts index 425a09c..db3dfb2 100644 --- a/apps/cli/main.ts +++ b/apps/cli/main.ts @@ -838,6 +838,59 @@ async function discordBot(): Promise { await bot.run(abort.signal); } +// ---------------------------------------------------------------- rewards:anlegen + +/** + * Eine Kanalpunkt-Belohnung ueber die API anlegen statt im Dashboard. + * + * Der Grund ist nicht Bequemlichkeit: Twitch laesst nur die App, die eine + * Belohnung angelegt hat, deren Einloesungen aendern. Eine im Dashboard + * angelegte Belohnung kann das Toolkit weder erstatten noch als erledigt + * markieren. Wiederholbar: steht die Belohnung schon, wird nur ihre ID + * gezeigt. + */ +async function rewardsAnlegen(args: string[]): Promise { + const wert = (name: string): string | undefined => { + const i = args.indexOf(name); + return i === -1 ? undefined : args[i + 1]; + }; + const titel = wert('--titel'); + const kosten = Number(wert('--kosten')); + if (!titel || !Number.isInteger(kosten) || kosten < 1) { + throw new Error('Aufruf: rewards:anlegen --titel "Nachricht vorlesen" --kosten 1000 ' + + '[--text] [--hinweis "..."] [--abklingzeit SEKUNDEN]'); + } + const cfg = loadConfig(); + const { helix } = await import('#twitch/helix.ts'); + const user = await helix.userByLogin(cfg.channel.login); + if (!user) throw new Error(`Kanal ${cfg.channel.login} nicht gefunden`); + + const alle = await helix.customRewards(user.id); + const eigene = new Set((await helix.customRewards(user.id, true)).map((r) => r.id)); + const da = alle.find((r) => r.title.toLowerCase() === titel.toLowerCase()); + if (da) { + console.log(`Steht schon: "${da.title}" (${da.cost} Punkte), ID ${da.id}`); + if (!eigene.has(da.id)) { + console.log('Achtung: im Dashboard angelegt, nicht vom Toolkit. Erstatten und ' + + 'Abschliessen gehen damit nicht. Im Dashboard loeschen und hier neu anlegen.'); + } + return; + } + + const abklingzeit = Number(wert('--abklingzeit') ?? 0); + const neu = await helix.createCustomReward(user.id, { + title: titel, + cost: kosten, + ...(args.includes('--text') ? { is_user_input_required: true } : {}), + ...(wert('--hinweis') ? { prompt: wert('--hinweis')! } : {}), + ...(abklingzeit > 0 + ? { is_global_cooldown_enabled: true, global_cooldown_seconds: abklingzeit } : {}), + should_redemptions_skip_request_queue: false, + }); + console.log(`Angelegt: "${neu.title}" (${neu.cost} Punkte), ID ${neu.id}`); + console.log('In config.yaml unter rewards.belohnungen mit `id:` eintragen - die ID gewinnt gegen den Titel.'); +} + // ---------------------------------------------------------------- help function help(): void { @@ -860,6 +913,9 @@ abdulive - Werkzeugkasten fuer Abdus Stream discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit) golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen Kanal ohne Ping, mit --echt in die Ankuendigungen + rewards:anlegen --titel T --kosten N [--text] [--hinweis H] [--abklingzeit S] + Kanalpunkt-Belohnung ueber die API anlegen (noetig + zum Erstatten, siehe apps/hub/src/rewards.ts) help Diese Uebersicht Einrichtung nach dem Start: http://localhost:4792/setup @@ -892,6 +948,7 @@ try { case 'render': await renderOne(args[0], args[1]); break; case 'discord:setup': await discordSetup(args); break; case 'golive:test': await goLiveTest(args); break; + case 'rewards:anlegen': await rewardsAnlegen(args); break; case 'hub': { const { runHub } = await import('../hub/src/index.ts'); await runHub(); diff --git a/apps/hub/src/audio-ablage.test.ts b/apps/hub/src/audio-ablage.test.ts new file mode 100644 index 0000000..0860595 --- /dev/null +++ b/apps/hub/src/audio-ablage.test.ts @@ -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); +}); diff --git a/apps/hub/src/audio-ablage.ts b/apps/hub/src/audio-ablage.ts new file mode 100644 index 0000000..c8d8d4b --- /dev/null +++ b/apps/hub/src/audio-ablage.ts @@ -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(); + + 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(), + }; +} diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 1fdf044..4dd93cc 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -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 { const warteschlange = createWarteschlange(); const drossel = createDrossel(); - /** Punkte zurueckgeben. Ohne den Scope nicht moeglich - dann nur melden. */ - const erstatten = async (rewardId: string, redemptionId: string): Promise => { + /** + * 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 => { + 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 => + 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 => { + 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): void => { @@ -149,10 +190,11 @@ export async function runHub(): Promise { 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 { 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 { 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 { 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'); diff --git a/apps/hub/src/rewards.test.ts b/apps/hub/src/rewards.test.ts index 4a4442d..d86ce9a 100644 --- a/apps/hub/src/rewards.test.ts +++ b/apps/hub/src/rewards.test.ts @@ -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: [ diff --git a/apps/hub/src/rewards.ts b/apps/hub/src/rewards.ts index e11b54b..e54f6fd 100644 --- a/apps/hub/src/rewards.ts +++ b/apps/hub/src/rewards.ts @@ -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 = { + '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 = { diff --git a/apps/overlay-server/src/bus.ts b/apps/overlay-server/src/bus.ts index 3f72ed7..d389946 100644 --- a/apps/overlay-server/src/bus.ts +++ b/apps/overlay-server/src/bus.ts @@ -81,7 +81,11 @@ export type OverlayEvent = * Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub * (apps/hub/src/rewards.ts), nicht hier. */ - | { kind: 'tts'; id: string; name: string; text: string } + | { + kind: 'tts'; id: string; name: string; text: string; + /** Liegt unter /tts/audio/ bereit. Ohne Ton wird nur angezeigt. */ + audio?: boolean; + } /** Alles sofort still. Fuer den Panikschalter. */ | { kind: 'still' } | { kind: 'scene'; scene: string } diff --git a/apps/overlay-server/src/server.ts b/apps/overlay-server/src/server.ts index 9aa8c48..2716b05 100644 --- a/apps/overlay-server/src/server.ts +++ b/apps/overlay-server/src/server.ts @@ -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.`); diff --git a/apps/overlay-web/README.md b/apps/overlay-web/README.md index bd3130d..f640ca5 100644 --- a/apps/overlay-web/README.md +++ b/apps/overlay-web/README.md @@ -89,15 +89,23 @@ auf einen echten Raid wartet. Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts. -Gesprochen wird über `speechSynthesis`, die im Browser eingebaute -Sprachausgabe. Kein Cloud-Dienst, keine Kosten, kein Zuschauertext, der das -Haus verlässt. **Ob OBS' Browser-Quelle das kann, ist offen** — CEF wird -teils ohne Sprachausgabe gebaut. Die Seite prüft das beim Start und blendet -einen roten Hinweis ein, wenn sie nicht sprechen kann; im Stream soll das -auffallen und nicht stillschweigend fehlen. +Gesprochen wird im Hub mit Piper, nicht im Browser. Die eingebaute +Sprachausgabe von OBS' Browser-Quelle sah keine deutsche Stimme und las +amerikanisch; welche Stimmen es gibt, hängt am Rechner, der streamt. Piper +läuft lokal im LXC, kostet nichts, und kein Zuschauertext verlässt das Haus. +Die Seite holt sich die fertige WAV unter `/tts/audio/` und spielt sie +über ein `