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
@@ -838,6 +838,59 @@ async function discordBot(): Promise<void> {
|
|||||||
await bot.run(abort.signal);
|
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<void> {
|
||||||
|
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
|
// ---------------------------------------------------------------- help
|
||||||
|
|
||||||
function help(): void {
|
function help(): void {
|
||||||
@@ -860,6 +913,9 @@ abdulive - Werkzeugkasten fuer Abdus Stream
|
|||||||
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
||||||
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
|
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
|
||||||
Kanal ohne Ping, mit --echt in die Ankuendigungen
|
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
|
help Diese Uebersicht
|
||||||
|
|
||||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||||
@@ -892,6 +948,7 @@ try {
|
|||||||
case 'render': await renderOne(args[0], args[1]); break;
|
case 'render': await renderOne(args[0], args[1]); break;
|
||||||
case 'discord:setup': await discordSetup(args); break;
|
case 'discord:setup': await discordSetup(args); break;
|
||||||
case 'golive:test': await goLiveTest(args); break;
|
case 'golive:test': await goLiveTest(args); break;
|
||||||
|
case 'rewards:anlegen': await rewardsAnlegen(args); break;
|
||||||
case 'hub': {
|
case 'hub': {
|
||||||
const { runHub } = await import('../hub/src/index.ts');
|
const { runHub } = await import('../hub/src/index.ts');
|
||||||
await runHub();
|
await runHub();
|
||||||
|
|||||||
@@ -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 { createEventSub } from '#twitch/eventsub.ts';
|
||||||
import { createIrc, type UserNotice } from '#twitch/irc.ts';
|
import { createIrc, type UserNotice } from '#twitch/irc.ts';
|
||||||
import { streamWechsel } from '#twitch/stream-state.ts';
|
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||||
|
import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
|
||||||
import {
|
import {
|
||||||
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
||||||
} from './rewards.ts';
|
} from './rewards.ts';
|
||||||
|
import { createAudioAblage } from './audio-ablage.ts';
|
||||||
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||||
import { createSpikeDetector } from '../../ingest/src/spike.ts';
|
import { createSpikeDetector } from '../../ingest/src/spike.ts';
|
||||||
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.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 warteschlange = createWarteschlange();
|
||||||
const drossel = createDrossel();
|
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 (!state.broadcasterId) return;
|
||||||
if (!helix.hasScope('channel:manage:redemptions')) {
|
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.');
|
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, 'CANCELED');
|
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, status);
|
||||||
} catch (err) {
|
} 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 => {
|
// ---- Vorlesen. Die Stimme entsteht hier im Hub (Piper), nicht im Browser:
|
||||||
bus.publish({ kind: 'tts', id, name, text });
|
// 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)}`);
|
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 => {
|
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 === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
|
||||||
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
|
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
|
||||||
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
|
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
|
||||||
|
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'vorlesen':
|
case 'vorlesen':
|
||||||
vorlesen(ulid(), e.userName, ent.text);
|
void vorlesen(ulid(), e.userName, ent.text, { rewardId: e.rewardId, redemptionId: e.id });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'warteschlange':
|
case 'warteschlange':
|
||||||
@@ -279,6 +321,7 @@ export async function runHub(): Promise<void> {
|
|||||||
goLive: announcer.lastResult() ?? 'noch nichts',
|
goLive: announcer.lastResult() ?? 'noch nichts',
|
||||||
overlayClients: bus.clients(),
|
overlayClients: bus.clients(),
|
||||||
ttsWartend: warteschlange.liste().length,
|
ttsWartend: warteschlange.liste().length,
|
||||||
|
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
|
||||||
spikeArmed: spikes.isArmed(),
|
spikeArmed: spikes.isArmed(),
|
||||||
note: state.chatAnonymous
|
note: state.chatAnonymous
|
||||||
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
||||||
@@ -291,7 +334,7 @@ export async function runHub(): Promise<void> {
|
|||||||
freigeben: (id) => {
|
freigeben: (id) => {
|
||||||
const w = warteschlange.entnehmen(id);
|
const w = warteschlange.entnehmen(id);
|
||||||
if (!w) return false;
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
ablehnen: (id) => {
|
ablehnen: (id) => {
|
||||||
@@ -307,8 +350,11 @@ export async function runHub(): Promise<void> {
|
|||||||
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
|
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
|
||||||
return alle.length;
|
return alle.length;
|
||||||
},
|
},
|
||||||
|
probe: (name, text) => { void vorlesen(ulid(), name, text); },
|
||||||
|
audio: (id) => audio.holen(id),
|
||||||
still: () => {
|
still: () => {
|
||||||
bus.publish({ kind: 'still' });
|
bus.publish({ kind: 'still' });
|
||||||
|
audio.leeren();
|
||||||
const alle = warteschlange.leeren();
|
const alle = warteschlange.leeren();
|
||||||
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
||||||
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
|
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
|
||||||
|
|||||||
@@ -4,10 +4,36 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
ausEventSub, createDrossel, createWarteschlange, entscheiden, planFuer,
|
ausEventSub, createDrossel, createWarteschlange, entscheiden, fuerFilter, planFuer,
|
||||||
type Einloesung, type RewardsConfig,
|
wortfilterTreffer, type Einloesung, type RewardsConfig,
|
||||||
} from './rewards.ts';
|
} 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 = {
|
const CFG: RewardsConfig = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
belohnungen: [
|
belohnungen: [
|
||||||
|
|||||||
+45
-3
@@ -105,9 +105,7 @@ export function entscheiden(
|
|||||||
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
|
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const klein = text.toLowerCase();
|
if (wortfilterTreffer(text, cfg.tts.blockliste)) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
||||||
const treffer = cfg.tts.blockliste.find((w) => w && klein.includes(w.toLowerCase()));
|
|
||||||
if (treffer) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
|
||||||
|
|
||||||
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
|
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
|
||||||
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
|
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 };
|
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
|
// ---------------------------------------------------------------- Warteschlange
|
||||||
|
|
||||||
export type Wartend = {
|
export type Wartend = {
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ export type OverlayEvent =
|
|||||||
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
|
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
|
||||||
* (apps/hub/src/rewards.ts), nicht hier.
|
* (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/<id> bereit. Ohne Ton wird nur angezeigt. */
|
||||||
|
audio?: boolean;
|
||||||
|
}
|
||||||
/** Alles sofort still. Fuer den Panikschalter. */
|
/** Alles sofort still. Fuer den Panikschalter. */
|
||||||
| { kind: 'still' }
|
| { kind: 'still' }
|
||||||
| { kind: 'scene'; scene: string }
|
| { kind: 'scene'; scene: string }
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export type OverlayOptions = {
|
|||||||
ablehnen: (id: string) => boolean;
|
ablehnen: (id: string) => boolean;
|
||||||
alleAblehnen: () => number;
|
alleAblehnen: () => number;
|
||||||
still: () => void;
|
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.
|
// ---- Die Browser-Sources und ihr Datenstrom.
|
||||||
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
|
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)) {
|
if (!zugang.darfOverlay(req, url)) {
|
||||||
return send(res, 403, 'text/plain; charset=utf-8',
|
return send(res, 403, 'text/plain; charset=utf-8',
|
||||||
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
|
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
|
||||||
'Sie steht fertig auf der Uebersichtsseite.');
|
'Sie steht fertig auf der Uebersichtsseite.');
|
||||||
}
|
}
|
||||||
if (p === '/events') return sse(req, res, bus);
|
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);
|
return sendFile(res, page!.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +234,13 @@ async function handle(
|
|||||||
const chat = zustand(bus).chat;
|
const chat = zustand(bus).chat;
|
||||||
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
||||||
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
|
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);
|
bus.publish(ev);
|
||||||
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
||||||
return antwort(`${knopf.label} ausgelöst.`);
|
return antwort(`${knopf.label} ausgelöst.`);
|
||||||
|
|||||||
@@ -89,15 +89,23 @@ auf einen echten Raid wartet.
|
|||||||
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
||||||
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
||||||
|
|
||||||
Gesprochen wird über `speechSynthesis`, die im Browser eingebaute
|
Gesprochen wird im Hub mit Piper, nicht im Browser. Die eingebaute
|
||||||
Sprachausgabe. Kein Cloud-Dienst, keine Kosten, kein Zuschauertext, der das
|
Sprachausgabe von OBS' Browser-Quelle sah keine deutsche Stimme und las
|
||||||
Haus verlässt. **Ob OBS' Browser-Quelle das kann, ist offen** — CEF wird
|
amerikanisch; welche Stimmen es gibt, hängt am Rechner, der streamt. Piper
|
||||||
teils ohne Sprachausgabe gebaut. Die Seite prüft das beim Start und blendet
|
läuft lokal im LXC, kostet nichts, und kein Zuschauertext verlässt das Haus.
|
||||||
einen roten Hinweis ein, wenn sie nicht sprechen kann; im Stream soll das
|
Die Seite holt sich die fertige WAV unter `/tts/audio/<id>` und spielt sie
|
||||||
auffallen und nicht stillschweigend fehlen.
|
über ein `<audio>` ab, das sicher über OBS' Tonspur läuft.
|
||||||
|
|
||||||
Einstellungen: `?sprache=de-DE`, `?tempo=1`, `?laut=1`.
|
Kann der Hub nicht sprechen, springt `speechSynthesis` ein, aber nur mit
|
||||||
|
einer deutschen Stimme. Gibt es keine, wird die Nachricht nur angezeigt,
|
||||||
|
mit rotem Hinweis.
|
||||||
|
|
||||||
Vorgelesen wird nur, was auf der Übersichtsseite freigegeben wurde. Nach einem
|
Stimme und Sprecher: `rewards.tts.stimme` und `rewards.tts.sprecher` in der
|
||||||
Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
Konfiguration, eingerichtet mit `scripts/install-piper.sh`.
|
||||||
|
|
||||||
|
Einstellungen: `?laut=1` (0 bis 1). Besser im OBS-Mixer regeln.
|
||||||
|
`?sprache=de-DE` und `?tempo=1` gelten nur für den Notnagel im Browser.
|
||||||
|
|
||||||
|
Mit `freigabe: true` wird nur vorgelesen, was auf der Übersichtsseite
|
||||||
|
freigegeben wurde. Nach einem Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
||||||
vorzulesen ist schlimmer, als sie zu verpassen.
|
vorzulesen ist schlimmer, als sie zu verpassen.
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
}
|
}
|
||||||
.was { font-size: 22px; line-height: 1.35; }
|
.was { font-size: 22px; line-height: 1.35; }
|
||||||
|
|
||||||
/* Ein Hinweis nur fuer den Fall, dass die Browser-Quelle keine Sprachausgabe
|
/* Ein Hinweis fuer den Fall, dass eine Nachricht ohne Ton bleibt. Im Stream
|
||||||
* kann. Im Stream soll das auffallen, nicht stillschweigend fehlen. */
|
* soll das auffallen, nicht stillschweigend fehlen. */
|
||||||
#fehlt {
|
#fehlt {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 16px; top: 16px;
|
left: 16px; top: 16px;
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="fehlt">
|
<div id="fehlt">
|
||||||
Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut).
|
Kein Ton: der Hub konnte nicht sprechen, und hier gibt es keine deutsche
|
||||||
Vorgelesen wird nichts.
|
Stimme. Die Nachricht wird nur angezeigt.
|
||||||
</div>
|
</div>
|
||||||
<div id="kasten"><div class="wer" id="wer"></div><div class="was" id="was"></div></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 sprache = Overlay.param('sprache', 'de-DE');
|
||||||
var tempo = Overlay.param('tempo', 1);
|
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 warteschlange = [];
|
||||||
var laeuft = false;
|
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
|
/* Gesprochen wird im Hub (Piper), hier wird nur abgespielt. Das hat zwei
|
||||||
* Laufzeit: OBS' eingebauter Browser wird teils ohne Sprachausgabe gebaut.
|
* Gruende: die eingebaute Sprachausgabe von OBS' Browser sah keine deutsche
|
||||||
* Ohne diese Pruefung bliebe es im Stream einfach still, und niemand wuesste
|
* Stimme und las amerikanisch, und ein <audio> laeuft sicher ueber OBS'
|
||||||
* warum. */
|
* Tonspur statt am Mixer vorbei.
|
||||||
var kannSprechen = typeof window.speechSynthesis !== 'undefined' &&
|
*
|
||||||
typeof window.SpeechSynthesisUtterance !== 'undefined';
|
* Die Browser-Sprachausgabe bleibt nur als Notnagel, wenn der Hub keinen
|
||||||
if (!kannSprechen) {
|
* Ton liefern konnte - und auch dann nur mit einer deutschen Stimme. Eine
|
||||||
document.getElementById('fehlt').style.display = 'block';
|
* englische Stimme, die deutschen Text radebrecht, ist schlimmer als Stille. */
|
||||||
console.error('[tts] Diese Browser-Quelle hat keine Sprachausgabe.');
|
function deutscheStimme() {
|
||||||
}
|
if (typeof window.speechSynthesis === 'undefined' ||
|
||||||
|
typeof window.SpeechSynthesisUtterance === 'undefined') return null;
|
||||||
/* 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;
|
|
||||||
var alle = window.speechSynthesis.getVoices() || [];
|
var alle = window.speechSynthesis.getVoices() || [];
|
||||||
var passend = alle.filter(function (v) { return v.lang && v.lang.indexOf(sprache.slice(0, 2)) === 0; });
|
var kurz = sprache.slice(0, 2).toLowerCase();
|
||||||
return passend[0] || alle[0] || null;
|
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) {
|
function zeige(name, text) {
|
||||||
@@ -95,47 +97,71 @@
|
|||||||
wasEl.textContent = text;
|
wasEl.textContent = text;
|
||||||
kasten.classList.add('an');
|
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() {
|
function naechste() {
|
||||||
var n = warteschlange.shift();
|
var n = warteschlange.shift();
|
||||||
|
aktuell = null;
|
||||||
if (!n) { laeuft = false; verstecke(); return; }
|
if (!n) { laeuft = false; verstecke(); return; }
|
||||||
laeuft = true;
|
laeuft = true;
|
||||||
zeige(n.name, n.text);
|
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 weiter = false;
|
||||||
var gehWeiter = function () {
|
var gehWeiter = function () {
|
||||||
if (weiter) return;
|
if (weiter) return;
|
||||||
weiter = true;
|
weiter = true;
|
||||||
setTimeout(naechste, 400);
|
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.onend = gehWeiter;
|
||||||
rede.onerror = function (e) {
|
rede.onerror = function (e) {
|
||||||
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
||||||
gehWeiter();
|
gehWeiter();
|
||||||
};
|
};
|
||||||
// Notbremse: bleibt die Sprachausgabe haengen, steht die Warteschlange
|
aktuell = { stopp: function () { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } } };
|
||||||
// sonst fuer den Rest des Streams. Grosszuegig bemessen.
|
|
||||||
setTimeout(gehWeiter, 5000 + n.text.length * 250);
|
|
||||||
|
|
||||||
window.speechSynthesis.speak(rede);
|
window.speechSynthesis.speak(rede);
|
||||||
}
|
}
|
||||||
|
|
||||||
Overlay.on('tts', function (ev) {
|
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 (warteschlange.length > 10) warteschlange.splice(0, warteschlange.length - 10);
|
||||||
if (!laeuft) naechste();
|
if (!laeuft) naechste();
|
||||||
});
|
});
|
||||||
@@ -143,7 +169,8 @@
|
|||||||
// Panikschalter: sofort still, Warteschlange weg.
|
// Panikschalter: sofort still, Warteschlange weg.
|
||||||
Overlay.on('still', function () {
|
Overlay.on('still', function () {
|
||||||
warteschlange.length = 0;
|
warteschlange.length = 0;
|
||||||
if (kannSprechen) { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } }
|
if (aktuell) aktuell.stopp();
|
||||||
|
aktuell = null;
|
||||||
laeuft = false;
|
laeuft = false;
|
||||||
verstecke();
|
verstecke();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,13 @@ rewards:
|
|||||||
# Satz ist schlimmer als gar keiner.
|
# Satz ist schlimmer als gar keiner.
|
||||||
blockliste: []
|
blockliste: []
|
||||||
proPersonProStunde: 3
|
proPersonProStunde: 3
|
||||||
|
# Gesprochen wird im Hub mit Piper, nicht im Browser - dort haengt die
|
||||||
|
# Stimme am Rechner, der streamt. Name einer Datei unter models/piper/
|
||||||
|
# (ohne .onnx) oder ein absoluter Pfad. Einrichten: scripts/install-piper.sh
|
||||||
|
stimme: de_DE-mls-medium
|
||||||
|
# mls hat 236 Sprecher in einem Modell; hier steht die Nummer (0-235).
|
||||||
|
# Bei Stimmen mit nur einem Sprecher weglassen.
|
||||||
|
sprecher: 0
|
||||||
|
|
||||||
# Overlay-Server. Zugleich die einzige Tuer nach aussen, wenn der Dienst
|
# Overlay-Server. Zugleich die einzige Tuer nach aussen, wenn der Dienst
|
||||||
# zentral laeuft: Uebersicht, Einrichtung und OAuth-Rueckruf haengen daran.
|
# zentral laeuft: Uebersicht, Einrichtung und OAuth-Rueckruf haengen daran.
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export type Config = {
|
|||||||
tts: {
|
tts: {
|
||||||
freigabe: boolean; maxZeichen: number;
|
freigabe: boolean; maxZeichen: number;
|
||||||
blockliste: string[]; proPersonProStunde: number;
|
blockliste: string[]; proPersonProStunde: number;
|
||||||
|
/** Piper-Stimme: Name im Modellordner (models/piper/<name>.onnx) oder absoluter Pfad. */
|
||||||
|
stimme: string;
|
||||||
|
/** Sprecher-Nummer bei Stimmen mit mehreren Sprechern. Leer: der erste. */
|
||||||
|
sprecher?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
overlay: { port: number; enabled: boolean };
|
overlay: { port: number; enabled: boolean };
|
||||||
@@ -142,6 +146,7 @@ const DEFAULTS = {
|
|||||||
maxZeichen: 200,
|
maxZeichen: 200,
|
||||||
blockliste: [] as string[],
|
blockliste: [] as string[],
|
||||||
proPersonProStunde: 3,
|
proPersonProStunde: 3,
|
||||||
|
stimme: 'de_DE-mls-medium',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
overlay: { port: 4791, enabled: true },
|
overlay: { port: 4791, enabled: true },
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Piper selbst laeuft hier nicht - das Programm gibt es nur im Hub. Geprueft
|
||||||
|
// wird, was vorher mit dem fremden Text passiert.
|
||||||
|
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { piperFehlt, sprechen, stimmenPfad, textFuerPiper } from './piper.ts';
|
||||||
|
|
||||||
|
test('Zeilenumbrueche werden zu Leerzeichen, sonst liest Piper zwei Durchlaeufe', () => {
|
||||||
|
assert.equal(textFuerPiper('Hallo\nWelt\r\nwie gehts'), 'Hallo Welt wie gehts');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Steuerzeichen fliegen raus, Umlaute und Emoji bleiben', () => {
|
||||||
|
assert.equal(textFuerPiper('Grüße\u0007 aus\u0000 Köln 🎉'), 'Grüße aus Köln 🎉');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leerer Text wird gar nicht erst gesprochen', async () => {
|
||||||
|
await assert.rejects(sprechen(' \n ', { model: '/gibt/es/nicht.onnx' }), /kein Text/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fehlendes Programm meldet sich als Fehler statt als Stille', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
sprechen('Hallo', { binary: '/gibt/es/nicht/piper', model: '/gibt/es/nicht.onnx' }),
|
||||||
|
/Piper nicht startbar/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('piperFehlt nennt Programm und Stimme', () => {
|
||||||
|
const fehlt = piperFehlt({ binary: '/gibt/es/nicht/piper', model: '/gibt/es/nicht.onnx' });
|
||||||
|
assert.equal(fehlt.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Stimmenname wird zum Pfad im Modellordner, ein absoluter Pfad bleibt', () => {
|
||||||
|
assert.match(stimmenPfad('de_DE-thorsten-medium'), /piper[\\/]de_DE-thorsten-medium\.onnx$/);
|
||||||
|
const abs = process.platform === 'win32' ? 'C:\\x\\s.onnx' : '/x/s.onnx';
|
||||||
|
assert.equal(stimmenPfad(abs), abs);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Text in Sprache, mit Piper.
|
||||||
|
//
|
||||||
|
// Warum nicht mehr `speechSynthesis` im Browser: in OBS' Browser-Quelle
|
||||||
|
// kam eine amerikanische Stimme heraus, weil der eingebaute Browser keine
|
||||||
|
// deutsche sah. Welche Stimmen es gibt, haengt am Rechner, der streamt - und
|
||||||
|
// damit an etwas, das hier niemand in der Hand hat. Piper laeuft im Hub, die
|
||||||
|
// Stimme liegt als Datei daneben und klingt auf jedem Rechner gleich.
|
||||||
|
//
|
||||||
|
// Gemessen im LXC (2 Kerne): 200 Zeichen in 0,7 s, rund 11 s Audio. Das ist
|
||||||
|
// schnell genug, um es pro Nachricht frisch zu erzeugen.
|
||||||
|
//
|
||||||
|
// Der Text geht ueber stdin hinein, nie als Argument - er ist fremder Text
|
||||||
|
// aus dem Chat, und so kommt er gar nicht erst in die Naehe einer Shell.
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { paths } from '#core/config.ts';
|
||||||
|
import { findBinary } from '#core/platform.ts';
|
||||||
|
import { logger } from '#core/logger.ts';
|
||||||
|
|
||||||
|
const log = logger('piper');
|
||||||
|
|
||||||
|
export type PiperOptions = {
|
||||||
|
binary?: string;
|
||||||
|
/** Pfad zur .onnx-Datei. Die .onnx.json muss daneben liegen. */
|
||||||
|
model: string;
|
||||||
|
/**
|
||||||
|
* Nummer des Sprechers bei Modellen mit mehreren (z. B. `mls` mit 236).
|
||||||
|
* Ohne Angabe nimmt Piper den ersten.
|
||||||
|
*/
|
||||||
|
speaker?: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pfad zum Stimmenmodell aus seinem Namen, z. B. `de_DE-thorsten-medium`. */
|
||||||
|
export function stimmenPfad(name: string): string {
|
||||||
|
return path.isAbsolute(name) ? name : path.join(paths.models, 'piper', `${name}.onnx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const piperBinary = (): string => findBinary('piper', 'PIPER_BIN');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text so herrichten, dass Piper ihn als einen Satzblock liest.
|
||||||
|
*
|
||||||
|
* Piper liest zeilenweise: jede Zeile waere ein eigener Durchlauf. Steuer-
|
||||||
|
* zeichen haben in gesprochenem Text nichts verloren.
|
||||||
|
*/
|
||||||
|
export function textFuerPiper(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was fehlt, damit gesprochen werden kann. Leer heisst: alles da. */
|
||||||
|
export function piperFehlt(opts: PiperOptions): string[] {
|
||||||
|
const fehlt: string[] = [];
|
||||||
|
const bin = opts.binary ?? piperBinary();
|
||||||
|
// Ein blanker Name wird erst beim Start im PATH gesucht; das laesst sich
|
||||||
|
// hier nicht vorab pruefen, also nur absolute Pfade.
|
||||||
|
if (path.isAbsolute(bin) && !fs.existsSync(bin)) fehlt.push(`Programm ${bin}`);
|
||||||
|
if (!fs.existsSync(opts.model)) fehlt.push(`Stimme ${opts.model}`);
|
||||||
|
else if (!fs.existsSync(`${opts.model}.json`)) fehlt.push(`Stimmen-Einstellungen ${opts.model}.json`);
|
||||||
|
return fehlt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzeugt eine WAV-Datei im Speicher. */
|
||||||
|
export function sprechen(text: string, opts: PiperOptions): Promise<Buffer> {
|
||||||
|
const eingabe = textFuerPiper(text);
|
||||||
|
if (!eingabe) return Promise.reject(new Error('kein Text'));
|
||||||
|
const bin = opts.binary ?? piperBinary();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// `--output_file -` schreibt die fertige WAV samt Kopf nach stdout; eine
|
||||||
|
// Zwischendatei braucht es damit nicht.
|
||||||
|
const args = ['--model', opts.model, '--output_file', '-', '--quiet'];
|
||||||
|
if (opts.speaker !== undefined) args.push('--speaker', String(opts.speaker));
|
||||||
|
const child = spawn(bin, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const teile: Buffer[] = [];
|
||||||
|
let fehler = '';
|
||||||
|
let fertig = false;
|
||||||
|
const ende = (fn: () => void): void => {
|
||||||
|
if (fertig) return;
|
||||||
|
fertig = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => ende(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error('Piper hat das Zeitlimit ueberschritten'));
|
||||||
|
}), opts.timeoutMs ?? 20_000);
|
||||||
|
|
||||||
|
child.stdout.on('data', (d: Buffer) => teile.push(d));
|
||||||
|
child.stderr.on('data', (d: Buffer) => { fehler = (fehler + d.toString()).slice(-2000); });
|
||||||
|
child.on('error', (err) => ende(() => reject(new Error(`Piper nicht startbar (${bin}): ${err.message}`))));
|
||||||
|
child.on('close', (code) => ende(() => {
|
||||||
|
const wav = Buffer.concat(teile);
|
||||||
|
if (code !== 0 || wav.length < 44) {
|
||||||
|
reject(new Error(`Piper beendet mit Code ${code}: ${fehler.trim().split('\n').pop() ?? ''}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.debug(`${eingabe.length} Zeichen -> ${Math.round(wav.length / 1024)} KB`);
|
||||||
|
resolve(wav);
|
||||||
|
}));
|
||||||
|
|
||||||
|
child.stdin.on('error', () => { /* Prozess schon weg, das meldet 'close' */ });
|
||||||
|
child.stdin.end(eingabe + '\n');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -49,6 +49,23 @@ export type HelixStream = {
|
|||||||
|
|
||||||
export type HelixUser = { id: string; login: string; display_name: string };
|
export type HelixUser = { id: string; login: string; display_name: string };
|
||||||
|
|
||||||
|
export type HelixReward = {
|
||||||
|
id: string; title: string; cost: number; prompt: string;
|
||||||
|
is_enabled: boolean; is_user_input_required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Was beim Anlegen einer Belohnung mitgeht. Namen wie in der Helix-API. */
|
||||||
|
export type NeueBelohnung = {
|
||||||
|
title: string;
|
||||||
|
cost: number;
|
||||||
|
prompt?: string;
|
||||||
|
is_user_input_required?: boolean;
|
||||||
|
is_global_cooldown_enabled?: boolean;
|
||||||
|
global_cooldown_seconds?: number;
|
||||||
|
/** Die Einloesung sofort als erledigt markieren. Aus, damit wir erstatten koennen. */
|
||||||
|
should_redemptions_skip_request_queue?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `appOk` heisst: dieser Endpunkt kommt auch ohne Nutzer-Token aus.
|
* `appOk` heisst: dieser Endpunkt kommt auch ohne Nutzer-Token aus.
|
||||||
*
|
*
|
||||||
@@ -176,6 +193,28 @@ export const helix = {
|
|||||||
{ method: 'PATCH', body: JSON.stringify({ status }) });
|
{ method: 'PATCH', body: JSON.stringify({ status }) });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Kanalpunkt-Belohnungen des Kanals. Mit `nurEigene` nur die, die diese
|
||||||
|
* App angelegt hat - und nur deren Einloesungen darf sie spaeter aendern,
|
||||||
|
* also auch erstatten. Eine im Dashboard angelegte Belohnung taugt dafuer
|
||||||
|
* nicht.
|
||||||
|
*/
|
||||||
|
async customRewards(broadcasterId: string, nurEigene = false): Promise<HelixReward[]> {
|
||||||
|
const r = await call<{ data: HelixReward[] }>('broadcaster',
|
||||||
|
`/channel_points/custom_rewards?broadcaster_id=${broadcasterId}` +
|
||||||
|
(nurEigene ? '&only_manageable_rewards=true' : ''));
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCustomReward(broadcasterId: string, reward: NeueBelohnung): Promise<HelixReward> {
|
||||||
|
const r = await call<{ data: HelixReward[] }>('broadcaster',
|
||||||
|
`/channel_points/custom_rewards?broadcaster_id=${broadcasterId}`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(reward) });
|
||||||
|
const angelegt = r.data[0];
|
||||||
|
if (!angelegt) throw new Error('Twitch hat keine Belohnung zurueckgegeben');
|
||||||
|
return angelegt;
|
||||||
|
},
|
||||||
|
|
||||||
/** Hat das verbundene Konto diesen Scope freigegeben? */
|
/** Hat das verbundene Konto diesen Scope freigegeben? */
|
||||||
hasScope(scope: string): boolean {
|
hasScope(scope: string): boolean {
|
||||||
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
|
return (readSecrets().tokens.broadcaster?.scopes ?? []).includes(scope);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Piper und die deutsche Stimme in den Hub-LXC bringen.
|
||||||
|
#
|
||||||
|
# Einmalig noetig, danach nur wieder fuer eine andere Stimme. Das Deploy-
|
||||||
|
# Skript fasst beides nicht an: Piper liegt unter /opt/piper, die Stimme im
|
||||||
|
# Modellordner, und beides ueberlebt jedes Ausrollen.
|
||||||
|
#
|
||||||
|
# Piper kommt als fertiges Paket mit eigenem espeak-ng und onnxruntime - im
|
||||||
|
# Container muss nichts aus apt dazu. Die Version ist festgenagelt und am
|
||||||
|
# 2026-09-23 im LXC geprueft: 200 Zeichen in 0,7 s.
|
||||||
|
#
|
||||||
|
# Aufruf: scripts/install-piper.sh
|
||||||
|
# Umgebung (mit Vorgaben):
|
||||||
|
# PVE_HOST=192.168.2.20 Proxmox-Host
|
||||||
|
# CT=141 Container-ID
|
||||||
|
# STIMME=de_DE-mls-medium Name aus huggingface.co/rhasspy/piper-voices
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PVE_HOST="${PVE_HOST:-192.168.2.20}"
|
||||||
|
CT="${CT:-141}"
|
||||||
|
STIMME="${STIMME:-de_DE-mls-medium}"
|
||||||
|
|
||||||
|
# de_DE-thorsten-medium -> de/de_DE/thorsten/medium
|
||||||
|
IFS=- read -r LOCALE SPRECHER QUALI <<< "$STIMME"
|
||||||
|
SPRACHE="${LOCALE%%_*}"
|
||||||
|
VOICES="https://huggingface.co/rhasspy/piper-voices/resolve/main/${SPRACHE}/${LOCALE}/${SPRECHER}/${QUALI}"
|
||||||
|
PIPER="https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz"
|
||||||
|
|
||||||
|
ssh "root@${PVE_HOST}" "pct exec ${CT} -- bash -lc '
|
||||||
|
set -e
|
||||||
|
if [ ! -x /opt/piper/piper ]; then
|
||||||
|
echo \"Lade Piper ...\"
|
||||||
|
curl -fsSL -o /tmp/piper.tgz ${PIPER}
|
||||||
|
tar xzf /tmp/piper.tgz -C /opt --no-same-owner
|
||||||
|
rm -f /tmp/piper.tgz
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /var/lib/abdulive/models/piper
|
||||||
|
cd /var/lib/abdulive/models/piper
|
||||||
|
if [ ! -f ${STIMME}.onnx ]; then
|
||||||
|
echo \"Lade Stimme ${STIMME} ...\"
|
||||||
|
curl -fsSL -o ${STIMME}.onnx.json ${VOICES}/${STIMME}.onnx.json
|
||||||
|
curl -fsSL -o ${STIMME}.onnx.part ${VOICES}/${STIMME}.onnx
|
||||||
|
mv ${STIMME}.onnx.part ${STIMME}.onnx
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Der Dienst findet Piper ueber PIPER_BIN. Ein Drop-in statt einer
|
||||||
|
# Aenderung an der Unit, damit ein neues Ausrollen der Unit das nicht
|
||||||
|
# ueberschreibt.
|
||||||
|
mkdir -p /etc/systemd/system/abdulive-hub.service.d
|
||||||
|
printf \"[Service]\nEnvironment=PIPER_BIN=/opt/piper/piper\n\" \
|
||||||
|
> /etc/systemd/system/abdulive-hub.service.d/piper.conf
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
echo \"Probe ...\"
|
||||||
|
echo \"Hallo, hier spricht der Hub.\" \
|
||||||
|
| /opt/piper/piper --model ${STIMME}.onnx --output_file - --quiet 2>/dev/null \
|
||||||
|
| head -c 4 | grep -q RIFF && echo \"Piper spricht.\"
|
||||||
|
'"
|
||||||
|
|
||||||
|
echo "Fertig. Wirksam nach dem naechsten Neustart des Hubs (scripts/deploy-hub.sh)."
|
||||||
Reference in new issue
Block a user