Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58e1d98351 | ||
|
|
2c298a3427 | ||
|
|
63ea40d5a8 | ||
|
|
b1cf0f8fab |
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 }
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export const PAGES = [
|
|||||||
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
||||||
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
||||||
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
|
title: 'Chat', hint: 'Nachrichten laufen herein und verblassen wieder.' },
|
||||||
|
{ path: '/kamera', file: 'kamera.html', w: 520, h: 380,
|
||||||
|
title: 'Kamerarahmen', hint: 'Rahmen um die Facecam mit Zuschauern und Laufzeit. Position 0,0, über der Kamera.' },
|
||||||
{ path: '/status', file: 'status.html', w: 1920, h: 160,
|
{ path: '/status', file: 'status.html', w: 1920, h: 160,
|
||||||
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
||||||
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
||||||
@@ -89,6 +91,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 +168,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 +236,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.
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>abdulive - Kamerarahmen</title>
|
||||||
|
<link rel="stylesheet" href="/theme.css">
|
||||||
|
<style>
|
||||||
|
/* Rahmen um die Facecam, dazu die Werte der alten Statusleiste auf einer
|
||||||
|
* Platte darunter.
|
||||||
|
*
|
||||||
|
* Die Kamera liegt bündig oben links, 480x270 (docs/obs-layout.md). Genau
|
||||||
|
* dieser Bereich wird für die Shorts ausgeschnitten, und das Gameplay ab
|
||||||
|
* x=515. Deshalb liegt hier NICHTS über der Kamera: der Rahmen nutzt nur den
|
||||||
|
* Streifen rechts (480 bis 515) und den freien Platz darunter. Wer den
|
||||||
|
* Rahmen nach innen zieht, hat ihn in jedem Short.
|
||||||
|
*
|
||||||
|
* Oben und links gibt es keinen Rahmen - dort ist der Bildrand.
|
||||||
|
*
|
||||||
|
* In OBS: Browser-Quelle 520x380, Position 0,0, über der Kamera. */
|
||||||
|
:root {
|
||||||
|
--cam-w: 480px;
|
||||||
|
--cam-h: 270px;
|
||||||
|
--dicke: 7px;
|
||||||
|
--abstand: 4px; /* Luft zwischen Kamera und Rahmen */
|
||||||
|
--platte-h: 74px;
|
||||||
|
--verlauf: linear-gradient(135deg, var(--lila), var(--pink), var(--tuerkis), var(--lila));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Rahmen: ein L um die rechte und untere Kante, außen abgerundet.
|
||||||
|
* Gezeichnet als Fläche mit ausgespartem Inneren, damit der Farbverlauf
|
||||||
|
* ohne Stoß um die Ecke läuft. */
|
||||||
|
#rahmen {
|
||||||
|
position: fixed;
|
||||||
|
left: 0; top: 0;
|
||||||
|
width: calc(var(--cam-w) + var(--abstand) + var(--dicke));
|
||||||
|
height: calc(var(--cam-h) + var(--abstand) + var(--dicke));
|
||||||
|
border-bottom-right-radius: 22px;
|
||||||
|
background: var(--verlauf);
|
||||||
|
background-size: 300% 300%;
|
||||||
|
animation: wandern 10s linear infinite;
|
||||||
|
-webkit-mask:
|
||||||
|
linear-gradient(#000 0 0) top left / calc(var(--cam-w) + var(--abstand)) calc(var(--cam-h) + var(--abstand)) no-repeat,
|
||||||
|
linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
filter: drop-shadow(0 0 10px rgba(169, 112, 255, .45));
|
||||||
|
transition: filter .4s;
|
||||||
|
}
|
||||||
|
@keyframes wandern { to { background-position: 300% 300%; } }
|
||||||
|
|
||||||
|
/* Platte unter der Kamera. Hängt am Rahmen, damit beides als ein Stück
|
||||||
|
* gelesen wird und nicht als zwei Overlays, die zufällig nah beieinander
|
||||||
|
* liegen. */
|
||||||
|
#platte {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: calc(var(--cam-h) + var(--abstand) + var(--dicke));
|
||||||
|
width: calc(var(--cam-w) + var(--abstand) + var(--dicke));
|
||||||
|
height: var(--platte-h);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 18px 0 16px;
|
||||||
|
background: var(--tinte-klar);
|
||||||
|
backdrop-filter: blur(14px) saturate(140%);
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 16px;
|
||||||
|
box-shadow: var(--schatten), var(--glanz);
|
||||||
|
}
|
||||||
|
|
||||||
|
.links { min-width: 0; }
|
||||||
|
.zeile1 { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.punkt {
|
||||||
|
width: 11px; height: 11px; flex: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
body.live .punkt { background: var(--rot); animation: klopfen 2s ease-out infinite; }
|
||||||
|
@keyframes klopfen {
|
||||||
|
0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, .55); }
|
||||||
|
70% { box-shadow: 0 0 0 12px rgba(239, 68, 68, 0); }
|
||||||
|
100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
|
||||||
|
}
|
||||||
|
.kanal { font-size: 23px; font-weight: 700; letter-spacing: -.01em; line-height: 1.1; }
|
||||||
|
#kategorie {
|
||||||
|
font-size: 15px; color: var(--schrift-leise); margin-top: 3px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.werte { display: flex; gap: 18px; }
|
||||||
|
.wert { text-align: right; }
|
||||||
|
.wert .zahl {
|
||||||
|
display: flex; align-items: center; justify-content: flex-end; gap: 6px;
|
||||||
|
font: 700 22px/1.1 var(--font-zahl);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.wert svg { width: 17px; height: 17px; opacity: .8; }
|
||||||
|
.wert .beschriftung {
|
||||||
|
font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase;
|
||||||
|
color: var(--schrift-leise);
|
||||||
|
}
|
||||||
|
.zahl.neu { animation: aufleuchten .8s ease-out; }
|
||||||
|
@keyframes aufleuchten {
|
||||||
|
0% { color: var(--tuerkis); transform: translateY(-3px); }
|
||||||
|
100% { color: inherit; transform: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nicht live: der Rahmen wird ruhig und grau. So sieht man in der
|
||||||
|
* Vorschau sofort, ob der Hub den Stream erkannt hat. */
|
||||||
|
body:not(.live) #rahmen {
|
||||||
|
background: linear-gradient(135deg, #3a3550, #57507a);
|
||||||
|
animation: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aufleuchten bei Abo, Raid, Bits: der Rahmen glüht kurz in der Farbe des
|
||||||
|
* Ereignisses. Die große Einblendung macht alerts.html - das hier ist nur
|
||||||
|
* das Echo an der Kamera, damit der Blick dorthin geht, wo Abdu reagiert. */
|
||||||
|
body.puls #rahmen { animation: wandern 1.2s linear infinite; }
|
||||||
|
body.puls-lila #rahmen { filter: drop-shadow(0 0 22px var(--lila)); }
|
||||||
|
body.puls-pink #rahmen { filter: drop-shadow(0 0 22px var(--pink)); }
|
||||||
|
body.puls-gold #rahmen { filter: drop-shadow(0 0 22px var(--gold)); }
|
||||||
|
body.puls-tuerkis #rahmen { filter: drop-shadow(0 0 22px var(--tuerkis)); }
|
||||||
|
|
||||||
|
/* Nur zum Ansehen im Browser (?probe=1): ein Platzhalter, wo die Kamera
|
||||||
|
* sitzt. In OBS liegt dort das echte Bild. */
|
||||||
|
#kamera-probe {
|
||||||
|
display: none;
|
||||||
|
position: fixed; left: 0; top: 0;
|
||||||
|
width: var(--cam-w); height: var(--cam-h);
|
||||||
|
background:
|
||||||
|
radial-gradient(40% 55% at 50% 60%, #4a3b6b, transparent 70%),
|
||||||
|
linear-gradient(160deg, #2b2440, #17131f);
|
||||||
|
color: var(--schrift-leise);
|
||||||
|
font: 500 14px/1 var(--font-zahl);
|
||||||
|
display: none; place-items: center;
|
||||||
|
}
|
||||||
|
body.probe #kamera-probe { display: grid; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="kamera-probe">Facecam 480 × 270</div>
|
||||||
|
<div id="rahmen"></div>
|
||||||
|
<div id="platte">
|
||||||
|
<div class="links">
|
||||||
|
<div class="zeile1">
|
||||||
|
<span class="punkt"></span>
|
||||||
|
<span class="kanal kontur" id="kanal">abdulive_</span>
|
||||||
|
</div>
|
||||||
|
<div id="kategorie">offline</div>
|
||||||
|
</div>
|
||||||
|
<div class="werte">
|
||||||
|
<div class="wert">
|
||||||
|
<span class="zahl" id="zuschauer">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>
|
||||||
|
</svg><span>-</span>
|
||||||
|
</span>
|
||||||
|
<span class="beschriftung">Zuschauer</span>
|
||||||
|
</div>
|
||||||
|
<div class="wert">
|
||||||
|
<span class="zahl" id="laufzeit"><span>-</span></span>
|
||||||
|
<span class="beschriftung">live seit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="getrennt">keine Verbindung</div>
|
||||||
|
|
||||||
|
<script src="/client.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
if (Overlay.param('probe', false)) document.body.classList.add('probe');
|
||||||
|
|
||||||
|
var kategorie = document.getElementById('kategorie');
|
||||||
|
var zuschauer = document.getElementById('zuschauer');
|
||||||
|
var laufzeit = document.getElementById('laufzeit');
|
||||||
|
document.getElementById('kanal').textContent = Overlay.param('kanal', 'abdulive_');
|
||||||
|
var seit = null;
|
||||||
|
|
||||||
|
function setze(el, wert) {
|
||||||
|
var ziel = el.lastElementChild;
|
||||||
|
if (ziel.textContent === wert) return;
|
||||||
|
ziel.textContent = wert;
|
||||||
|
el.classList.remove('neu');
|
||||||
|
void el.offsetWidth;
|
||||||
|
el.classList.add('neu');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stand(ev) {
|
||||||
|
document.body.classList.toggle('live', !!ev.live);
|
||||||
|
// Die Kategorie statt des Titels: der Titel ist meist zu lang für 490 px
|
||||||
|
// und steht ohnehin auf der Twitch-Seite direkt unter dem Bild.
|
||||||
|
kategorie.textContent = ev.live ? (ev.category || 'live') : 'offline';
|
||||||
|
setze(zuschauer, ev.live ? Overlay.zahl(ev.viewers) : '-');
|
||||||
|
seit = ev.live ? ev.startedAt : null;
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
function tick() { laufzeit.lastElementChild.textContent = seit ? Overlay.laufzeit(seit) : '-'; }
|
||||||
|
setInterval(tick, 10000);
|
||||||
|
|
||||||
|
var FARBE = {
|
||||||
|
sub: 'lila', resub: 'lila', subgift: 'pink', massgift: 'pink',
|
||||||
|
raid: 'tuerkis', cheer: 'gold', follow: 'tuerkis', reward: 'gold',
|
||||||
|
};
|
||||||
|
var pulsTimer = null;
|
||||||
|
function puls(farbe) {
|
||||||
|
var b = document.body;
|
||||||
|
b.className = b.className.replace(/\bpuls(-\w+)?\b/g, '').trim();
|
||||||
|
void b.offsetWidth;
|
||||||
|
b.classList.add('puls', 'puls-' + farbe);
|
||||||
|
clearTimeout(pulsTimer);
|
||||||
|
pulsTimer = setTimeout(function () {
|
||||||
|
b.className = b.className.replace(/\bpuls(-\w+)?\b/g, '').trim();
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
Overlay.on('stream', stand);
|
||||||
|
Overlay.on('state', function (ev) { stand(ev.stream); });
|
||||||
|
Overlay.on('alert', function (ev) { puls(FARBE[ev.type] || 'lila'); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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.
|
||||||
|
|||||||
+69
-17
@@ -1,6 +1,6 @@
|
|||||||
# Übergabe
|
# Übergabe
|
||||||
|
|
||||||
Stand **2026-09-17**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
|
Stand **2026-09-23**, letztes gebautes Paket 0.5.4. Wer hier weitermacht,
|
||||||
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
|
liest zuerst diese Seite, dann [`07-mvp-plan.md`](07-mvp-plan.md). Für Abdu
|
||||||
selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
|
selbst gibt es das [Handbuch](handbuch/handbuch.pdf).
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Auslöser bis zum fertigen Short steht im Code vollständig. Geprüft ist sie
|
|||||||
| S5 Onboarding + Sichtung | offen | |
|
| S5 Onboarding + Sichtung | offen | |
|
||||||
| S6 Erstchatter-Begrüßung | offen | |
|
| S6 Erstchatter-Begrüßung | offen | |
|
||||||
|
|
||||||
193 Tests, alle grün. Was noch fehlt, steht vollständig in
|
280 Tests, alle grün. Was noch fehlt, steht vollständig in
|
||||||
[`offene-punkte.md`](offene-punkte.md).
|
[`offene-punkte.md`](offene-punkte.md).
|
||||||
|
|
||||||
## Neu: der Dienst läuft zentral
|
## Neu: der Dienst läuft zentral
|
||||||
@@ -47,9 +47,10 @@ systemd (`abdulive-hub`). Der Container hat keinen Zugang zum Repo - das
|
|||||||
Skript schiebt den Arbeitsstand als Archiv hinüber.
|
Skript schiebt den Arbeitsstand als Archiv hinüber.
|
||||||
|
|
||||||
Der Discord-Teil der Zugangsdaten ist übertragen, der Bot hängt am Hub und
|
Der Discord-Teil der Zugangsdaten ist übertragen, der Bot hängt am Hub und
|
||||||
meldet beim Start "Rollen-Knöpfe sind scharf". Die Twitch-Broadcaster-App ist
|
meldet beim Start "Rollen-Knöpfe sind scharf". **Abdu hat am 2026-09-23 sein
|
||||||
hinterlegt und geprüft (Client-ID registriert, Secret passt - Twitch stellt
|
Konto verbunden** (`abdulive_`, alle 9 Scopes). Damit laufen Helix mit
|
||||||
darauf ein App-Token aus). **Was fehlt, ist der Klick von Abdu**, siehe unten.
|
Nutzer-Token und EventSub. Der Chat liest weiter anonym mit, weil das
|
||||||
|
Bot-Konto noch nicht verbunden ist.
|
||||||
|
|
||||||
**Das kostet ADR-0005 seine Hauptbegründung.** "Bindet nur auf 127.0.0.1" gilt
|
**Das kostet ADR-0005 seine Hauptbegründung.** "Bindet nur auf 127.0.0.1" gilt
|
||||||
für den Hub nicht mehr. An seine Stelle treten ein Passwort für alles, was ein
|
für den Hub nicht mehr. An seine Stelle treten ein Passwort für alles, was ein
|
||||||
@@ -76,10 +77,9 @@ Der Hub steht auf `mode: draft` - der Post geht vollständig, aber in
|
|||||||
Webhook ist damit nachgewiesen. Zum Scharfschalten: `mode: live` in
|
Webhook ist damit nachgewiesen. Zum Scharfschalten: `mode: live` in
|
||||||
`/var/lib/abdulive/config/config.yaml`.
|
`/var/lib/abdulive/config/config.yaml`.
|
||||||
|
|
||||||
**Ausgelöst hat er noch nie**, und das liegt nicht am Broadcaster, sondern an
|
**Ausgelöst hat er noch nie.** Bis zum 2026-09-23 fehlte das
|
||||||
Twitch: die Erkennung des Stream-Starts hängt am Helix-Polling, und das
|
Broadcaster-Token; das ist jetzt da. Der nächste Stream ist der erste echte
|
||||||
braucht ein Broadcaster-Token. Solange das fehlt, wartet der Ping auf ein
|
Durchlauf, im Entwurfsmodus.
|
||||||
Ereignis, das nie kommt.
|
|
||||||
|
|
||||||
## Das Overlay steht
|
## Das Overlay steht
|
||||||
|
|
||||||
@@ -92,13 +92,61 @@ gegen die tote Browser-Source nach einem Neustart getan ist, steht in
|
|||||||
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
|
[`apps/overlay-server/README.md`](../apps/overlay-server/README.md).
|
||||||
|
|
||||||
Abos, Raids und Bits kommen über IRC-USERNOTICE herein, das wird jetzt
|
Abos, Raids und Bits kommen über IRC-USERNOTICE herein, das wird jetzt
|
||||||
ausgewertet. **Follows fehlen** - die gibt es nur über EventSub.
|
ausgewertet. **Follows fehlen** - die gibt es nur über EventSub. EventSub
|
||||||
|
läuft inzwischen, abonniert ist aber nur `channel_points_custom_reward_redemption.add`.
|
||||||
|
Follows wären ein zweites Abonnement (`channel.follow`, Version 2, braucht
|
||||||
|
`moderator:read:followers`, der Scope ist schon da).
|
||||||
|
|
||||||
|
**Kamerarahmen, Entwurf** (`/kamera`, 520×380 auf 0,0): ein Rahmen rechts und
|
||||||
|
unten um die Facecam, darunter eine Platte mit Kanal, Kategorie, Zuschauern
|
||||||
|
und Laufzeit. Soll die Statusleiste ersetzen, die als unschön empfunden wird.
|
||||||
|
Über der Kamera liegt bewusst nichts, weil genau dieser Bereich für die
|
||||||
|
Shorts ausgeschnitten wird. Noch nicht ausgerollt, Rückmeldung zur Richtung
|
||||||
|
steht aus. Ansehen: `node apps/cli/main.ts overlay --demo`, dann
|
||||||
|
`http://127.0.0.1:4791/kamera?probe=1`.
|
||||||
|
|
||||||
Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
|
Zum Ansehen ohne Stream: auf der Übersichtsseite gibt es unter
|
||||||
**Ausprobieren** für jede Einblendung, jede Chat-Variante und jeden Effekt
|
**Ausprobieren** für jede Einblendung, jede Chat-Variante und jeden Effekt
|
||||||
einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im
|
einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im
|
||||||
Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
|
Dauerbetrieb: `abdulive overlay --demo` oder der Knopf „Vorführung starten".
|
||||||
|
|
||||||
|
## Kanalpunkte: „Nachricht vorlesen" läuft
|
||||||
|
|
||||||
|
Seit 2026-09-23 scharf. Der Weg: EventSub meldet die Einlösung, der Hub
|
||||||
|
prüft (`apps/hub/src/rewards.ts`), Piper spricht, `/tts` spielt ab.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Belohnung | „Nachricht vorlesen", 1000 Punkte, Texteingabe Pflicht |
|
||||||
|
| Freigabe | keine, wird direkt vorgelesen |
|
||||||
|
| Schutz | Wortfilter (29 Einträge), 200 Zeichen, 3 je Person und Stunde, „Sofort still" auf der Übersichtsseite |
|
||||||
|
| Stimme | Piper, `de_DE-mls-medium`, Sprecher 0 |
|
||||||
|
|
||||||
|
**Die Stimme entsteht im Hub, nicht im Browser.** Zuerst lief es über
|
||||||
|
`speechSynthesis` in der Browser-Quelle. Das hat in OBS gesprochen, aber
|
||||||
|
amerikanisch: OBS' Browser sah keine deutsche Stimme und die Seite nahm die
|
||||||
|
erste beliebige. Piper liegt unter `/opt/piper` im LXC (2 Kerne: 200 Zeichen
|
||||||
|
in 0,7 s), das Stimmenmodell unter `models/piper/`. Beides überlebt jedes
|
||||||
|
Ausrollen; eingerichtet mit `scripts/install-piper.sh`. Proben aller 236
|
||||||
|
mls-Sprecher lassen sich mit `--speaker N` erzeugen.
|
||||||
|
|
||||||
|
**Die Belohnung muss über die API angelegt sein** (`abdulive rewards:anlegen`).
|
||||||
|
Nur die App, die eine Belohnung angelegt hat, darf deren Einlösungen
|
||||||
|
abschließen oder erstatten. Im Dashboard sieht man davon nichts. Titel,
|
||||||
|
Kosten und Abklingzeit darf man dort ändern; löschen und neu anlegen nicht,
|
||||||
|
dann ist die ID weg, die in der Hub-Konfiguration steht.
|
||||||
|
|
||||||
|
**Der Wortfilter normalisiert vorher**: Leetspeak, Punkte zwischen den
|
||||||
|
Buchstaben, gezogene Buchstaben, Akzente. Einträge ab 5 Buchstaben werden im
|
||||||
|
zusammengezogenen Text gesucht, kürzere nur am Wortanfang, sonst stünde
|
||||||
|
„nazi" in „kann azubi". Die Liste steht nur in der Hub-Konfiguration, nicht
|
||||||
|
im Repo.
|
||||||
|
|
||||||
|
**Der YAML-Leser konnte keine Listen aus Maps.** `- titel: X` zerfiel in
|
||||||
|
Zeichenketten, jede Belohnung war ohne Meldung unwirksam - auch das Beispiel
|
||||||
|
in `config.example.yaml`. Behoben und getestet; die bestehenden Dateien
|
||||||
|
werden nachweislich gleich gelesen wie vorher.
|
||||||
|
|
||||||
## Der Discord-Server steht
|
## Der Discord-Server steht
|
||||||
|
|
||||||
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
|
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
|
||||||
@@ -302,9 +350,9 @@ oben.
|
|||||||
|
|
||||||
## Die drei Dinge, die Abdu tun muss
|
## Die drei Dinge, die Abdu tun muss
|
||||||
|
|
||||||
1. **Broadcaster-Konto verbinden.** Das ist jetzt der Flaschenhals: ohne
|
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
|
||||||
Broadcaster-Token gibt es keine Zuschauerzahlen, keine Stream-Sitzungen und
|
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
|
||||||
damit auch keinen Go-Live-Ping.
|
muss.
|
||||||
|
|
||||||
Die beiden Twitch-Apps (dev.twitch.tv/console/apps) kann auch jemand anders
|
Die beiden Twitch-Apps (dev.twitch.tv/console/apps) kann auch jemand anders
|
||||||
anlegen, sie hängen nicht am Kanal. Seit dem Umzug in den Hub brauchen sie
|
anlegen, sie hängen nicht am Kanal. Seit dem Umzug in den Hub brauchen sie
|
||||||
@@ -326,7 +374,8 @@ oben.
|
|||||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
||||||
`abdulive calibrate` zur Kontrolle.
|
`abdulive calibrate` zur Kontrolle.
|
||||||
|
|
||||||
Ohne Punkt 1 liest das Toolkit den Chat anonym mit und kann nichts senden.
|
Das Bot-Konto ist noch nicht verbunden: bis dahin liest das Toolkit den
|
||||||
|
Chat anonym mit und kann nichts senden.
|
||||||
Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief.
|
Ohne Punkt 2 gibt es keine Clips. Ohne Punkt 3 sitzt der Ausschnitt schief.
|
||||||
|
|
||||||
Dazu eine Kleinigkeit mit Wirkung: das Bot-Konto sollte Moderator sein
|
Dazu eine Kleinigkeit mit Wirkung: das Bot-Konto sollte Moderator sein
|
||||||
@@ -354,9 +403,12 @@ malt. Das war vorher nicht zu erraten.
|
|||||||
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
|
`mode: live`. Das ist der kleinste verbleibende Handgriff mit der größten
|
||||||
Wirkung.
|
Wirkung.
|
||||||
|
|
||||||
**`apps/recap` bauen.** Der Webhook liegt bereit, der Hub hat die Daten, und
|
**Den Kamerarahmen fertig machen** und die Statusleiste damit ablösen.
|
||||||
anders als S2 hängt daran nichts. Guter Nebenweg, wenn der Stream-PC gerade
|
|
||||||
nicht verfügbar ist.
|
**`apps/recap` bauen.** Angefangen, aber nicht committet: `apps/recap/src/recap.ts`
|
||||||
|
und Ergänzungen in `packages/db/src/repos.ts` liegen nur im Arbeitsstand
|
||||||
|
(die Typprüfung meckert dort noch über `url: undefined`). Der Webhook liegt
|
||||||
|
bereit, der Hub hat die Daten.
|
||||||
|
|
||||||
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
|
**Danach S5.** Nicht wegen des Codes, sondern weil das Werkzeug ohne
|
||||||
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -53,3 +53,37 @@ test('ein Byte Order Mark verstellt nicht den ersten Schluessel', () => {
|
|||||||
assert.deepEqual(y, { channel: { login: 'abdulive_' } });
|
assert.deepEqual(y, { channel: { login: 'abdulive_' } });
|
||||||
assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff));
|
assert.ok(!Object.keys(y).some((k) => k.charCodeAt(0) === 0xfeff));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Listen aus Maps, wie in rewards.belohnungen', () => {
|
||||||
|
// Frueher zerfiel das in Zeichenketten wie "id: abc", und die Belohnung
|
||||||
|
// war ohne jede Meldung unwirksam.
|
||||||
|
const y = parseYaml([
|
||||||
|
'rewards:',
|
||||||
|
' enabled: true',
|
||||||
|
' belohnungen:',
|
||||||
|
' - id: abc-123',
|
||||||
|
' titel: Nachricht vorlesen',
|
||||||
|
' aktion: tts',
|
||||||
|
' - titel: Konfetti',
|
||||||
|
' aktion: konfetti',
|
||||||
|
' tts:',
|
||||||
|
' blockliste:',
|
||||||
|
' - sieg heil',
|
||||||
|
' - http',
|
||||||
|
' maxZeichen: 200',
|
||||||
|
].join('\n'));
|
||||||
|
assert.deepEqual(y, {
|
||||||
|
rewards: {
|
||||||
|
enabled: true,
|
||||||
|
belohnungen: [
|
||||||
|
{ id: 'abc-123', titel: 'Nachricht vorlesen', aktion: 'tts' },
|
||||||
|
{ titel: 'Konfetti', aktion: 'konfetti' },
|
||||||
|
],
|
||||||
|
tts: { blockliste: ['sieg heil', 'http'], maxZeichen: 200 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Listeneintrag mit Doppelpunkt ohne Leerzeichen bleibt Text', () => {
|
||||||
|
assert.deepEqual(parseYaml('a:\n - http://x.de\n'), { a: ['http://x.de'] });
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien.
|
// Minimaler YAML-Leser fuer unsere eigenen Konfigurationsdateien.
|
||||||
//
|
//
|
||||||
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
|
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
|
||||||
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren und
|
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren oder
|
||||||
// Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
|
// Maps (`- titel: X`) und Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
|
||||||
// kommt ohne npm-Abhaengigkeiten aus, und der Sprachumfang, den wir selbst
|
// kommt ohne npm-Abhaengigkeiten aus, und der Sprachumfang, den wir selbst
|
||||||
// schreiben, ist ueberschaubar.
|
// schreiben, ist ueberschaubar.
|
||||||
|
|
||||||
@@ -73,7 +73,20 @@ export function parseYaml(text: string): YamlMap {
|
|||||||
if (body.startsWith('- ')) {
|
if (body.startsWith('- ')) {
|
||||||
const key = frame.listKey;
|
const key = frame.listKey;
|
||||||
const list = key ? frame.target[key] : undefined;
|
const list = key ? frame.target[key] : undefined;
|
||||||
if (Array.isArray(list)) list.push(scalar(body.slice(2)));
|
const item = body.slice(2);
|
||||||
|
// `- titel: X` beginnt eine Map als Listeneintrag. Die Zeile wird mit
|
||||||
|
// der Einrueckung ihres Inhalts neu gelesen, die folgenden, tiefer
|
||||||
|
// eingerueckten Zeilen landen in derselben Map. Ohne das zerfiel
|
||||||
|
// `rewards.belohnungen` in einzelne Zeichenketten, und jede Belohnung
|
||||||
|
// war ohne Fehlermeldung unwirksam.
|
||||||
|
if (Array.isArray(list) && /^[^\s'"{[#-][^:]*:(\s|$)/.test(item)) {
|
||||||
|
const map: YamlMap = {};
|
||||||
|
list.push(map);
|
||||||
|
stack.push({ indent, target: map });
|
||||||
|
lines[i] = ' '.repeat(indent + 2) + item;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(list)) list.push(scalar(item));
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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