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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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
|
||||
|
||||
function help(): void {
|
||||
@@ -860,6 +913,9 @@ abdulive - Werkzeugkasten fuer Abdus Stream
|
||||
discord:bot Rollen-Knoepfe bedienen (laeuft sonst im Daemon mit)
|
||||
golive:test [--echt] Go-Live-Post proben: ohne Argument in den internen
|
||||
Kanal ohne Ping, mit --echt in die Ankuendigungen
|
||||
rewards:anlegen --titel T --kosten N [--text] [--hinweis H] [--abklingzeit S]
|
||||
Kanalpunkt-Belohnung ueber die API anlegen (noetig
|
||||
zum Erstatten, siehe apps/hub/src/rewards.ts)
|
||||
help Diese Uebersicht
|
||||
|
||||
Einrichtung nach dem Start: http://localhost:4792/setup
|
||||
@@ -892,6 +948,7 @@ try {
|
||||
case 'render': await renderOne(args[0], args[1]); break;
|
||||
case 'discord:setup': await discordSetup(args); break;
|
||||
case 'golive:test': await goLiveTest(args); break;
|
||||
case 'rewards:anlegen': await rewardsAnlegen(args); break;
|
||||
case 'hub': {
|
||||
const { runHub } = await import('../hub/src/index.ts');
|
||||
await runHub();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAudioAblage } from './audio-ablage.ts';
|
||||
|
||||
const wav = (n: number): Buffer => Buffer.alloc(44 + n);
|
||||
|
||||
test('abgelegter Ton ist unter seiner ID abholbar', () => {
|
||||
const a = createAudioAblage();
|
||||
a.ablegen('x', wav(1));
|
||||
assert.equal(a.holen('x')?.length, 45);
|
||||
assert.equal(a.holen('y'), undefined);
|
||||
});
|
||||
|
||||
test('laeuft sie voll, faellt das Aelteste heraus', () => {
|
||||
const a = createAudioAblage({ maximal: 2 });
|
||||
a.ablegen('1', wav(1));
|
||||
a.ablegen('2', wav(2));
|
||||
a.ablegen('3', wav(3));
|
||||
assert.equal(a.holen('1'), undefined);
|
||||
assert.ok(a.holen('2'));
|
||||
assert.ok(a.holen('3'));
|
||||
});
|
||||
|
||||
test('abgelaufener Ton ist weg', async () => {
|
||||
const a = createAudioAblage({ haltbarMs: 5 });
|
||||
a.ablegen('x', wav(1));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.equal(a.holen('x'), undefined);
|
||||
});
|
||||
|
||||
test('leeren wirft alles weg - fuer den Panikschalter', () => {
|
||||
const a = createAudioAblage();
|
||||
a.ablegen('x', wav(1));
|
||||
a.leeren();
|
||||
assert.equal(a.holen('x'), undefined);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// Fertig gesprochene Nachrichten, bis die Browser-Quelle sie abholt.
|
||||
//
|
||||
// Warum nicht direkt im Ereignis: eine WAV von 200 Zeichen hat gut 500 KB.
|
||||
// Der Ereignisstrom geht an alle Browser-Sources, auch an Chat und
|
||||
// Statusleiste - die sollen das nicht mitschleppen. Also geht nur die ID
|
||||
// hinaus, und `/tts` holt sich den Ton einzeln.
|
||||
//
|
||||
// Bewusst im Arbeitsspeicher und kurzlebig: niemand braucht eine vorgelesene
|
||||
// Nachricht von gestern, und fremder Text soll nicht auf der Platte liegen.
|
||||
|
||||
export type AudioAblage = {
|
||||
ablegen: (id: string, wav: Buffer) => void;
|
||||
holen: (id: string) => Buffer | undefined;
|
||||
leeren: () => void;
|
||||
};
|
||||
|
||||
export function createAudioAblage(opts: { maximal?: number; haltbarMs?: number } = {}): AudioAblage {
|
||||
const maximal = opts.maximal ?? 20;
|
||||
const haltbarMs = opts.haltbarMs ?? 10 * 60_000;
|
||||
// Map haelt die Einfuegereihenfolge; das Aelteste steht vorn.
|
||||
const ablage = new Map<string, { wav: Buffer; seit: number }>();
|
||||
|
||||
const aufraeumen = (jetzt: number): void => {
|
||||
for (const [id, e] of ablage) if (jetzt - e.seit > haltbarMs) ablage.delete(id);
|
||||
while (ablage.size > maximal) {
|
||||
const aeltestes = ablage.keys().next().value;
|
||||
if (aeltestes === undefined) break;
|
||||
ablage.delete(aeltestes);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ablegen(id, wav) {
|
||||
ablage.set(id, { wav, seit: Date.now() });
|
||||
aufraeumen(Date.now());
|
||||
},
|
||||
holen(id) {
|
||||
aufraeumen(Date.now());
|
||||
return ablage.get(id)?.wav;
|
||||
},
|
||||
leeren: () => ablage.clear(),
|
||||
};
|
||||
}
|
||||
+55
-9
@@ -32,9 +32,11 @@ import { readSecrets } from '#twitch/secrets.ts';
|
||||
import { createEventSub } from '#twitch/eventsub.ts';
|
||||
import { createIrc, type UserNotice } from '#twitch/irc.ts';
|
||||
import { streamWechsel } from '#twitch/stream-state.ts';
|
||||
import { piperFehlt, sprechen, stimmenPfad } from '#media/piper.ts';
|
||||
import {
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden,
|
||||
} from './rewards.ts';
|
||||
import { createAudioAblage } from './audio-ablage.ts';
|
||||
import { createAnnouncer, type StreamInfo } from '../../broadcaster/src/golive.ts';
|
||||
import { createSpikeDetector } from '../../ingest/src/spike.ts';
|
||||
import { createBus, type AlertEvent, type Bus } from '../../overlay-server/src/bus.ts';
|
||||
@@ -94,24 +96,63 @@ export async function runHub(): Promise<void> {
|
||||
const warteschlange = createWarteschlange();
|
||||
const drossel = createDrossel();
|
||||
|
||||
/** Punkte zurueckgeben. Ohne den Scope nicht moeglich - dann nur melden. */
|
||||
const erstatten = async (rewardId: string, redemptionId: string): Promise<void> => {
|
||||
/**
|
||||
* Eine Einloesung abschliessen (FULFILLED) oder die Punkte zurueckgeben
|
||||
* (CANCELED). Geht nur bei Belohnungen, die das Toolkit selbst angelegt hat
|
||||
* (`abdulive rewards:anlegen`) - sonst lehnt Twitch ab, und es bleibt bei
|
||||
* einer Warnung.
|
||||
*/
|
||||
const einloesungSetzen = async (
|
||||
rewardId: string, redemptionId: string, status: 'FULFILLED' | 'CANCELED',
|
||||
): Promise<void> => {
|
||||
const was = status === 'CANCELED' ? 'Punkte nicht erstattet' : 'Einloesung nicht abgeschlossen';
|
||||
if (!state.broadcasterId) return;
|
||||
if (!helix.hasScope('channel:manage:redemptions')) {
|
||||
log.warn('Punkte nicht erstattet: dem verbundenen Konto fehlt ' +
|
||||
log.warn(`${was}: dem verbundenen Konto fehlt ` +
|
||||
'channel:manage:redemptions. Einmal neu verbinden unter /setup.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, 'CANCELED');
|
||||
await helix.setRedemptionStatus(state.broadcasterId, rewardId, redemptionId, status);
|
||||
} catch (err) {
|
||||
log.warn(`Punkte nicht erstattet: ${(err as Error).message}`);
|
||||
log.warn(`${was}: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
const erstatten = (rewardId: string, redemptionId: string): Promise<void> =>
|
||||
einloesungSetzen(rewardId, redemptionId, 'CANCELED');
|
||||
|
||||
const vorlesen = (id: string, name: string, text: string): void => {
|
||||
bus.publish({ kind: 'tts', id, name, text });
|
||||
// ---- Vorlesen. Die Stimme entsteht hier im Hub (Piper), nicht im Browser:
|
||||
// dort haengt sie am Rechner, der streamt, und kam in OBS amerikanisch.
|
||||
const stimme = {
|
||||
model: stimmenPfad(cfg.rewards.tts.stimme),
|
||||
...(typeof cfg.rewards.tts.sprecher === 'number' ? { speaker: cfg.rewards.tts.sprecher } : {}),
|
||||
};
|
||||
const audio = createAudioAblage();
|
||||
const stimmeFehlt = piperFehlt(stimme);
|
||||
if (stimmeFehlt.length) {
|
||||
log.warn(`Vorlesen ohne Stimme - es fehlt: ${stimmeFehlt.join(', ')}. ` +
|
||||
'Einrichten mit scripts/install-piper.sh. Bis dahin wird nur angezeigt.');
|
||||
}
|
||||
|
||||
const vorlesen = async (
|
||||
id: string, name: string, text: string,
|
||||
/** Fehlt beim Testknopf - dann gibt es bei Twitch nichts abzuschliessen. */
|
||||
einloesung?: { rewardId: string; redemptionId: string },
|
||||
): Promise<void> => {
|
||||
let mitTon = false;
|
||||
try {
|
||||
audio.ablegen(id, await sprechen(text, stimme));
|
||||
mitTon = true;
|
||||
} catch (err) {
|
||||
// Lieber stumm anzeigen als gar nicht: wer Punkte ausgegeben hat, soll
|
||||
// seine Nachricht wenigstens im Bild sehen.
|
||||
log.warn(`Nicht gesprochen, nur angezeigt: ${(err as Error).message}`);
|
||||
}
|
||||
bus.publish({ kind: 'tts', id, name, text, audio: mitTon });
|
||||
log.info(`Wird vorgelesen (${name}): ${text.slice(0, 60)}`);
|
||||
// Auch ohne Ton abgeschlossen: angezeigt wurde es, und eine Einloesung,
|
||||
// die ewig in Twitchs Warteschlange haengt, raeumt niemand auf.
|
||||
if (einloesung) void einloesungSetzen(einloesung.rewardId, einloesung.redemptionId, 'FULFILLED');
|
||||
};
|
||||
|
||||
const behandleEinloesung = (roh: Record<string, unknown>): void => {
|
||||
@@ -149,10 +190,11 @@ export async function runHub(): Promise<void> {
|
||||
if (ent.aktion === 'konfetti') bus.publish({ kind: 'konfetti', staerke: 1 });
|
||||
if (ent.aktion === 'emote-regen') bus.publish({ kind: 'hype', score: 5, rate: 40 });
|
||||
log.info(`${e.userName} hat "${e.rewardTitle}" eingeloest -> ${ent.aktion}`);
|
||||
void einloesungSetzen(e.rewardId, e.id, 'FULFILLED');
|
||||
break;
|
||||
|
||||
case 'vorlesen':
|
||||
vorlesen(ulid(), e.userName, ent.text);
|
||||
void vorlesen(ulid(), e.userName, ent.text, { rewardId: e.rewardId, redemptionId: e.id });
|
||||
break;
|
||||
|
||||
case 'warteschlange':
|
||||
@@ -279,6 +321,7 @@ export async function runHub(): Promise<void> {
|
||||
goLive: announcer.lastResult() ?? 'noch nichts',
|
||||
overlayClients: bus.clients(),
|
||||
ttsWartend: warteschlange.liste().length,
|
||||
ttsStimme: stimmeFehlt.length ? `fehlt: ${stimmeFehlt.join(', ')}` : 'bereit',
|
||||
spikeArmed: spikes.isArmed(),
|
||||
note: state.chatAnonymous
|
||||
? 'Chat wird anonym mitgelesen. Zum Senden und fuer Helix: Einrichtung unter /setup.'
|
||||
@@ -291,7 +334,7 @@ export async function runHub(): Promise<void> {
|
||||
freigeben: (id) => {
|
||||
const w = warteschlange.entnehmen(id);
|
||||
if (!w) return false;
|
||||
vorlesen(w.id, w.name, w.text);
|
||||
void vorlesen(w.id, w.name, w.text, { rewardId: w.rewardId, redemptionId: w.redemptionId });
|
||||
return true;
|
||||
},
|
||||
ablehnen: (id) => {
|
||||
@@ -307,8 +350,11 @@ export async function runHub(): Promise<void> {
|
||||
if (alle.length) log.info(`${alle.length} wartende Nachricht(en) abgelehnt`);
|
||||
return alle.length;
|
||||
},
|
||||
probe: (name, text) => { void vorlesen(ulid(), name, text); },
|
||||
audio: (id) => audio.holen(id),
|
||||
still: () => {
|
||||
bus.publish({ kind: 'still' });
|
||||
audio.leeren();
|
||||
const alle = warteschlange.leeren();
|
||||
for (const w of alle) void erstatten(w.rewardId, w.redemptionId);
|
||||
log.warn('Panikschalter: Vorlesen gestoppt und Warteschlange geleert');
|
||||
|
||||
@@ -4,10 +4,36 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden, planFuer,
|
||||
type Einloesung, type RewardsConfig,
|
||||
ausEventSub, createDrossel, createWarteschlange, entscheiden, fuerFilter, planFuer,
|
||||
wortfilterTreffer, type Einloesung, type RewardsConfig,
|
||||
} from './rewards.ts';
|
||||
|
||||
// ---- Wortfilter. Vorgelesen wird ohne Freigabe, also muss er halten.
|
||||
|
||||
const LISTE = ['hitler', 'nazi'];
|
||||
|
||||
test('Wortfilter: Leetspeak, Punkte und gezogene Buchstaben helfen nicht', () => {
|
||||
for (const t of ['H1tl3r war super', 'h.i.t.l.e.r', 'H I T L E R', 'hiiiitlerrr', 'HİTLER']) {
|
||||
assert.equal(wortfilterTreffer(t, LISTE), 'hitler', t);
|
||||
}
|
||||
});
|
||||
|
||||
test('Wortfilter: kurze Eintraege nur am Wortanfang, sonst trifft es Harmloses', () => {
|
||||
assert.equal(wortfilterTreffer('ich kann azubi werden', LISTE), undefined);
|
||||
assert.equal(wortfilterTreffer('Gymnasium', LISTE), undefined);
|
||||
assert.equal(wortfilterTreffer('alles Nazis hier', LISTE), 'nazi');
|
||||
assert.equal(wortfilterTreffer('N4Z1', LISTE), 'nazi');
|
||||
});
|
||||
|
||||
test('Wortfilter: normaler Text geht durch', () => {
|
||||
assert.equal(wortfilterTreffer('Grüße an Abdu, geiler Stream!', LISTE), undefined);
|
||||
});
|
||||
|
||||
test('fuerFilter: Umlaute und ß werden zu dem, was man hoert', () => {
|
||||
assert.equal(fuerFilter('Größe', false), 'grose');
|
||||
assert.equal(fuerFilter('a-b c', true), 'a b c');
|
||||
});
|
||||
|
||||
const CFG: RewardsConfig = {
|
||||
enabled: true,
|
||||
belohnungen: [
|
||||
|
||||
+45
-3
@@ -105,9 +105,7 @@ export function entscheiden(
|
||||
return { art: 'abgelehnt', grund: `zu lang (${text.length} von ${max} Zeichen)` };
|
||||
}
|
||||
|
||||
const klein = text.toLowerCase();
|
||||
const treffer = cfg.tts.blockliste.find((w) => w && klein.includes(w.toLowerCase()));
|
||||
if (treffer) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
||||
if (wortfilterTreffer(text, cfg.tts.blockliste)) return { art: 'abgelehnt', grund: 'Wortfilter' };
|
||||
|
||||
if (bisherigeDieseStunde >= cfg.tts.proPersonProStunde) {
|
||||
return { art: 'abgelehnt', grund: 'zu viele Einloesungen in der letzten Stunde' };
|
||||
@@ -117,6 +115,50 @@ export function entscheiden(
|
||||
return freigabe ? { art: 'warteschlange', text } : { art: 'vorlesen', text };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Wortfilter
|
||||
|
||||
const LEET: Record<string, string> = {
|
||||
'0': 'o', '1': 'i', '3': 'e', '4': 'a', '5': 's', '7': 't', '8': 'b',
|
||||
'@': 'a', '$': 's', '!': 'i', '|': 'i',
|
||||
};
|
||||
|
||||
/**
|
||||
* Text auf das bringen, was man hoert: klein, ohne Akzente, Leetspeak
|
||||
* zurueckuebersetzt, Buchstabenketten auf einen gekuerzt. Wer "N 3 G G 3 R"
|
||||
* oder "h.i.t.l.e.r" schreibt, will am Filter vorbei - vorgelesen klingt es
|
||||
* trotzdem wie das Original.
|
||||
*
|
||||
* `mitLuecken` laesst Wortgrenzen als Leerzeichen stehen, sonst wird alles
|
||||
* zusammengezogen.
|
||||
*/
|
||||
export function fuerFilter(text: string, mitLuecken: boolean): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.normalize('NFKD').replace(/\p{M}/gu, '')
|
||||
.replace(/[0134578@$!|]/g, (c) => LEET[c] ?? c)
|
||||
.replace(/[^a-z]+/g, mitLuecken ? ' ' : '')
|
||||
.replace(/([a-z])\1+/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trifft der Text ein Wort der Blockliste?
|
||||
*
|
||||
* Lange Eintraege (ab 5 Buchstaben) werden im zusammengezogenen Text
|
||||
* gesucht, damit Punkte und Leerzeichen dazwischen nicht helfen. Kurze nur
|
||||
* am Wortanfang: zusammengezogen stuende "nazi" sonst auch in "kann azubi".
|
||||
*/
|
||||
export function wortfilterTreffer(text: string, blockliste: string[]): string | undefined {
|
||||
const dicht = fuerFilter(text, false);
|
||||
const woerter = fuerFilter(text, true).split(' ');
|
||||
return blockliste.find((eintrag) => {
|
||||
const e = fuerFilter(eintrag, false);
|
||||
if (!e) return false;
|
||||
return e.length >= 5 ? dicht.includes(e) : woerter.some((w) => w.startsWith(e));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Warteschlange
|
||||
|
||||
export type Wartend = {
|
||||
|
||||
@@ -81,7 +81,11 @@ export type OverlayEvent =
|
||||
* Belohnung ohne Freigabe eingerichtet ist - die Entscheidung faellt im Hub
|
||||
* (apps/hub/src/rewards.ts), nicht hier.
|
||||
*/
|
||||
| { kind: 'tts'; id: string; name: string; text: string }
|
||||
| {
|
||||
kind: 'tts'; id: string; name: string; text: string;
|
||||
/** Liegt unter /tts/audio/<id> bereit. Ohne Ton wird nur angezeigt. */
|
||||
audio?: boolean;
|
||||
}
|
||||
/** Alles sofort still. Fuer den Panikschalter. */
|
||||
| { kind: 'still' }
|
||||
| { kind: 'scene'; scene: string }
|
||||
|
||||
@@ -54,6 +54,8 @@ export const PAGES = [
|
||||
title: 'Einblendungen', hint: 'Abos, Raids, Bits, Clips. Gehört ganz nach oben.' },
|
||||
{ path: '/chat', file: 'chat.html', w: 460, h: 900,
|
||||
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,
|
||||
title: 'Statusleiste', hint: 'Titel, Kategorie, Laufzeit, Zuschauer.' },
|
||||
{ path: '/effects', file: 'effects.html', w: 1920, h: 1080,
|
||||
@@ -89,6 +91,10 @@ export type OverlayOptions = {
|
||||
ablehnen: (id: string) => boolean;
|
||||
alleAblehnen: () => number;
|
||||
still: () => void;
|
||||
/** Spricht einen Text sofort, ohne Warteschlange. Fuer den Testknopf. */
|
||||
probe: (name: string, text: string) => void;
|
||||
/** Die fertige WAV zu einer Nachricht, solange sie abholbar ist. */
|
||||
audio: (id: string) => Buffer | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -162,13 +168,23 @@ async function handle(
|
||||
|
||||
// ---- Die Browser-Sources und ihr Datenstrom.
|
||||
const page = PAGES.find((x) => x.path === p || `/${x.file}` === p);
|
||||
if (page || p === '/events') {
|
||||
const audioId = /^\/tts\/audio\/([A-Za-z0-9]+)$/.exec(p)?.[1];
|
||||
if (page || p === '/events' || audioId) {
|
||||
if (!zugang.darfOverlay(req, url)) {
|
||||
return send(res, 403, 'text/plain; charset=utf-8',
|
||||
'Kein Zugang. Die Adresse braucht den Schluessel: ?key=...\n' +
|
||||
'Sie steht fertig auf der Uebersichtsseite.');
|
||||
}
|
||||
if (p === '/events') return sse(req, res, bus);
|
||||
if (audioId) {
|
||||
const wav = opts.tts?.audio(audioId);
|
||||
if (!wav) return send(res, 404, 'text/plain; charset=utf-8', 'Nicht (mehr) da.');
|
||||
res.writeHead(200, {
|
||||
'content-type': 'audio/wav', 'content-length': wav.length, 'cache-control': 'no-store',
|
||||
});
|
||||
res.end(wav);
|
||||
return;
|
||||
}
|
||||
return sendFile(res, page!.file);
|
||||
}
|
||||
|
||||
@@ -220,6 +236,13 @@ async function handle(
|
||||
const chat = zustand(bus).chat;
|
||||
const ev = testEvent(knopf.art, chat[chat.length - 1]?.id);
|
||||
if (!ev) return antwort('Nichts zu löschen - der Chat ist leer.', 409);
|
||||
// Vorlesen geht durch den Hub, damit der Knopf die echte Stimme prueft
|
||||
// und nicht nur die Anzeige.
|
||||
if (ev.kind === 'tts' && opts.tts) {
|
||||
opts.tts.probe(ev.name, ev.text);
|
||||
log.info('Testereignis ausgeloest: tts (mit Stimme)');
|
||||
return antwort(`${knopf.label} ausgelöst.`);
|
||||
}
|
||||
bus.publish(ev);
|
||||
log.info(`Testereignis ausgeloest: ${knopf.art}`);
|
||||
return antwort(`${knopf.label} ausgelöst.`);
|
||||
|
||||
@@ -89,15 +89,23 @@ auf einen echten Raid wartet.
|
||||
Eigene Browser-Source, 600×200, **mit Ton**. In OBS darf „Audio über OBS
|
||||
steuern" nicht ausgeschaltet sein, sonst hört der Stream nichts.
|
||||
|
||||
Gesprochen wird über `speechSynthesis`, die im Browser eingebaute
|
||||
Sprachausgabe. Kein Cloud-Dienst, keine Kosten, kein Zuschauertext, der das
|
||||
Haus verlässt. **Ob OBS' Browser-Quelle das kann, ist offen** — CEF wird
|
||||
teils ohne Sprachausgabe gebaut. Die Seite prüft das beim Start und blendet
|
||||
einen roten Hinweis ein, wenn sie nicht sprechen kann; im Stream soll das
|
||||
auffallen und nicht stillschweigend fehlen.
|
||||
Gesprochen wird im Hub mit Piper, nicht im Browser. Die eingebaute
|
||||
Sprachausgabe von OBS' Browser-Quelle sah keine deutsche Stimme und las
|
||||
amerikanisch; welche Stimmen es gibt, hängt am Rechner, der streamt. Piper
|
||||
läuft lokal im LXC, kostet nichts, und kein Zuschauertext verlässt das Haus.
|
||||
Die Seite holt sich die fertige WAV unter `/tts/audio/<id>` und spielt sie
|
||||
ü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
|
||||
Neuverbinden wird **nichts nachgeholt** — eine Nachricht von vor zehn Minuten
|
||||
Stimme und Sprecher: `rewards.tts.stimme` und `rewards.tts.sprecher` in der
|
||||
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.
|
||||
@@ -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; }
|
||||
|
||||
/* Ein Hinweis nur fuer den Fall, dass die Browser-Quelle keine Sprachausgabe
|
||||
* kann. Im Stream soll das auffallen, nicht stillschweigend fehlen. */
|
||||
/* Ein Hinweis fuer den Fall, dass eine Nachricht ohne Ton bleibt. Im Stream
|
||||
* soll das auffallen, nicht stillschweigend fehlen. */
|
||||
#fehlt {
|
||||
position: fixed;
|
||||
left: 16px; top: 16px;
|
||||
@@ -49,8 +49,8 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="fehlt">
|
||||
Diese Browser-Quelle kann nicht sprechen (keine Sprachausgabe eingebaut).
|
||||
Vorgelesen wird nichts.
|
||||
Kein Ton: der Hub konnte nicht sprechen, und hier gibt es keine deutsche
|
||||
Stimme. Die Nachricht wird nur angezeigt.
|
||||
</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 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 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
|
||||
* Laufzeit: OBS' eingebauter Browser wird teils ohne Sprachausgabe gebaut.
|
||||
* Ohne diese Pruefung bliebe es im Stream einfach still, und niemand wuesste
|
||||
* warum. */
|
||||
var kannSprechen = typeof window.speechSynthesis !== 'undefined' &&
|
||||
typeof window.SpeechSynthesisUtterance !== 'undefined';
|
||||
if (!kannSprechen) {
|
||||
document.getElementById('fehlt').style.display = 'block';
|
||||
console.error('[tts] Diese Browser-Quelle hat keine Sprachausgabe.');
|
||||
}
|
||||
|
||||
/* 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;
|
||||
/* Gesprochen wird im Hub (Piper), hier wird nur abgespielt. Das hat zwei
|
||||
* Gruende: die eingebaute Sprachausgabe von OBS' Browser sah keine deutsche
|
||||
* Stimme und las amerikanisch, und ein <audio> laeuft sicher ueber OBS'
|
||||
* Tonspur statt am Mixer vorbei.
|
||||
*
|
||||
* Die Browser-Sprachausgabe bleibt nur als Notnagel, wenn der Hub keinen
|
||||
* Ton liefern konnte - und auch dann nur mit einer deutschen Stimme. Eine
|
||||
* englische Stimme, die deutschen Text radebrecht, ist schlimmer als Stille. */
|
||||
function deutscheStimme() {
|
||||
if (typeof window.speechSynthesis === 'undefined' ||
|
||||
typeof window.SpeechSynthesisUtterance === 'undefined') return null;
|
||||
var alle = window.speechSynthesis.getVoices() || [];
|
||||
var passend = alle.filter(function (v) { return v.lang && v.lang.indexOf(sprache.slice(0, 2)) === 0; });
|
||||
return passend[0] || alle[0] || null;
|
||||
var kurz = sprache.slice(0, 2).toLowerCase();
|
||||
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) {
|
||||
@@ -95,47 +97,71 @@
|
||||
wasEl.textContent = text;
|
||||
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() {
|
||||
var n = warteschlange.shift();
|
||||
aktuell = null;
|
||||
if (!n) { laeuft = false; verstecke(); return; }
|
||||
laeuft = true;
|
||||
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 gehWeiter = function () {
|
||||
if (weiter) return;
|
||||
weiter = true;
|
||||
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.onerror = function (e) {
|
||||
console.error('[tts] Sprachausgabe fehlgeschlagen', e && e.error);
|
||||
gehWeiter();
|
||||
};
|
||||
// Notbremse: bleibt die Sprachausgabe haengen, steht die Warteschlange
|
||||
// sonst fuer den Rest des Streams. Grosszuegig bemessen.
|
||||
setTimeout(gehWeiter, 5000 + n.text.length * 250);
|
||||
|
||||
aktuell = { stopp: function () { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } } };
|
||||
window.speechSynthesis.speak(rede);
|
||||
}
|
||||
|
||||
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 (!laeuft) naechste();
|
||||
});
|
||||
@@ -143,7 +169,8 @@
|
||||
// Panikschalter: sofort still, Warteschlange weg.
|
||||
Overlay.on('still', function () {
|
||||
warteschlange.length = 0;
|
||||
if (kannSprechen) { try { window.speechSynthesis.cancel(); } catch (e) { /* egal */ } }
|
||||
if (aktuell) aktuell.stopp();
|
||||
aktuell = null;
|
||||
laeuft = false;
|
||||
verstecke();
|
||||
});
|
||||
|
||||
@@ -152,6 +152,13 @@ rewards:
|
||||
# Satz ist schlimmer als gar keiner.
|
||||
blockliste: []
|
||||
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
|
||||
# zentral laeuft: Uebersicht, Einrichtung und OAuth-Rueckruf haengen daran.
|
||||
|
||||
+69
-17
@@ -1,6 +1,6 @@
|
||||
# Ü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
|
||||
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 | |
|
||||
| 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).
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
hinterlegt und geprüft (Client-ID registriert, Secret passt - Twitch stellt
|
||||
darauf ein App-Token aus). **Was fehlt, ist der Klick von Abdu**, siehe unten.
|
||||
meldet beim Start "Rollen-Knöpfe sind scharf". **Abdu hat am 2026-09-23 sein
|
||||
Konto verbunden** (`abdulive_`, alle 9 Scopes). Damit laufen Helix mit
|
||||
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
|
||||
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
|
||||
`/var/lib/abdulive/config/config.yaml`.
|
||||
|
||||
**Ausgelöst hat er noch nie**, und das liegt nicht am Broadcaster, sondern an
|
||||
Twitch: die Erkennung des Stream-Starts hängt am Helix-Polling, und das
|
||||
braucht ein Broadcaster-Token. Solange das fehlt, wartet der Ping auf ein
|
||||
Ereignis, das nie kommt.
|
||||
**Ausgelöst hat er noch nie.** Bis zum 2026-09-23 fehlte das
|
||||
Broadcaster-Token; das ist jetzt da. Der nächste Stream ist der erste echte
|
||||
Durchlauf, im Entwurfsmodus.
|
||||
|
||||
## 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).
|
||||
|
||||
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
|
||||
**Ausprobieren** für jede Einblendung, jede Chat-Variante und jeden Effekt
|
||||
einen Knopf. Die Ereignisse gehen nur an die Browser-Sources. Dasselbe im
|
||||
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
|
||||
|
||||
Dazugekommen seit der letzten Übergabe, und kein Teil des MVP: der
|
||||
@@ -302,9 +350,9 @@ oben.
|
||||
|
||||
## Die drei Dinge, die Abdu tun muss
|
||||
|
||||
1. **Broadcaster-Konto verbinden.** Das ist jetzt der Flaschenhals: ohne
|
||||
Broadcaster-Token gibt es keine Zuschauerzahlen, keine Stream-Sitzungen und
|
||||
damit auch keinen Go-Live-Ping.
|
||||
1. ~~**Broadcaster-Konto verbinden.**~~ Erledigt am 2026-09-23. Der Rest
|
||||
dieses Punkts bleibt als Nachschlag stehen, falls neu verbunden werden
|
||||
muss.
|
||||
|
||||
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
|
||||
@@ -326,7 +374,8 @@ oben.
|
||||
3. **Layout umstellen** nach [`obs-layout.md`](obs-layout.md), danach
|
||||
`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.
|
||||
|
||||
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
|
||||
Wirkung.
|
||||
|
||||
**`apps/recap` bauen.** Der Webhook liegt bereit, der Hub hat die Daten, und
|
||||
anders als S2 hängt daran nichts. Guter Nebenweg, wenn der Stream-PC gerade
|
||||
nicht verfügbar ist.
|
||||
**Den Kamerarahmen fertig machen** und die Statusleiste damit ablösen.
|
||||
|
||||
**`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
|
||||
Sichtungsseite für Abdu nicht bedienbar ist. Die Kette funktioniert, aber sie
|
||||
|
||||
@@ -66,6 +66,10 @@ export type Config = {
|
||||
tts: {
|
||||
freigabe: boolean; maxZeichen: 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 };
|
||||
@@ -142,6 +146,7 @@ const DEFAULTS = {
|
||||
maxZeichen: 200,
|
||||
blockliste: [] as string[],
|
||||
proPersonProStunde: 3,
|
||||
stimme: 'de_DE-mls-medium',
|
||||
},
|
||||
},
|
||||
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.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.
|
||||
//
|
||||
// Deckt genau das ab, was config/*.yaml benutzt: verschachtelte Maps ueber
|
||||
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren und
|
||||
// Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
|
||||
// Einrueckung, Skalare, Inline-Maps `{a: 1, b: 2}`, Listen aus Skalaren oder
|
||||
// Maps (`- titel: X`) und Blockskalare (`>` / `|`). Bewusst kein vollstaendiges YAML - das Projekt
|
||||
// kommt ohne npm-Abhaengigkeiten aus, und der Sprachumfang, den wir selbst
|
||||
// schreiben, ist ueberschaubar.
|
||||
|
||||
@@ -73,7 +73,20 @@ export function parseYaml(text: string): YamlMap {
|
||||
if (body.startsWith('- ')) {
|
||||
const key = frame.listKey;
|
||||
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++;
|
||||
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 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.
|
||||
*
|
||||
@@ -176,6 +193,28 @@ export const helix = {
|
||||
{ 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? */
|
||||
hasScope(scope: string): boolean {
|
||||
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