Vorlesen: Piper im Hub statt Sprachausgabe im Browser
In OBS' Browser-Quelle kam `speechSynthesis` amerikanisch heraus: der eingebaute Browser sah keine deutsche Stimme, und die Seite nahm die erste beliebige. Welche Stimmen es gibt, haengt am Rechner, der streamt. Jetzt spricht der Hub mit Piper (lokal im LXC, 200 Zeichen in 0,7 s). Die WAV liegt kurz im Speicher, `/tts` holt sie unter /tts/audio/<id> mit dem Overlay-Schluessel und spielt sie ueber <audio> ab - das laeuft sicher ueber OBS' Tonspur. Der Browser springt nur noch mit einer deutschen Stimme ein, sonst wird nur angezeigt. Stimme und Sprecher stehen in rewards.tts. Dazu: - `abdulive rewards:anlegen`: Belohnung ueber die API anlegen. Nur die App, die sie angelegt hat, darf Einloesungen abschliessen oder erstatten. - Einloesungen werden nach der Aktion als FULFILLED markiert, statt in Twitchs Warteschlange liegen zu bleiben. - Wortfilter normalisiert vorher: Leetspeak, Punkte dazwischen, gezogene Buchstaben, Akzente. Kurze Eintraege nur am Wortanfang. - Der Testknopf "Vorlesen" geht durch Piper und prueft so die echte Stimme. - scripts/install-piper.sh richtet Piper und Stimme im LXC ein. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b1cf0f8fab
commit
63ea40d5a8
16 files changed
+640
-68
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user