youtube/remotion/werkzeuge/liedstimmen.mts
Lara 966623c079 Lied 1: Gag mit Stimmen (Lulu fragt, Pino antwortet, Erzähler lässt nachzählen) und Apfelbiss
- gesprochen.ts: Zeilen je Sprache; werkzeuge/liedstimmen.mts erzeugt sie mit ElevenLabs
  (gleiche Stimmen wie die Folgen) nach public/stimmen/lieder/<lied>/<sprache>/
- Ablauf des Gags richtet sich nach den Aufnahmen (zeiten.ts), Musik leiser beim Sprechen
- neues Geräusch apfel_biss

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:12:22 +00:00

87 lines
4.2 KiB
TypeScript

// Erzeugt die gesprochenen Zeilen eines Liedvideos mit ElevenLabs (gleiche Stimmen und
// Einstellungen wie werkzeuge/stimmen.mts für die Folgen) und trägt ihre Länge in
// src/lieder/<lied>/stimmen/<sprache>.json ein. Die Zeilen stehen in src/lieder/<lied>/gesprochen.ts.
//
// npx tsx werkzeuge/liedstimmen.mts lied01 --sprache es
// … --neu alle Zeilen neu erzeugen
// … --probe nur anzeigen, was erzeugt würde (kostet nichts)
//
// Schlüssel aus ELEVENLABS_API_KEY bzw. youtube/.env.local (nicht im Git).
// Aufnahmen: public/stimmen/lieder/<lied>/<sprache>/<id>.mp3 (nicht im Git; auf dem VPS
// liegt public/stimmen als Link auf /shared/youtube/stimmen/).
import {spawnSync} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {Readable} from 'node:stream';
import {ElevenLabsClient} from '@elevenlabs/elevenlabs-js';
const WURZEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
process.chdir(WURZEL);
const envDatei = path.join(WURZEL, '..', '.env.local');
if (!process.env.ELEVENLABS_API_KEY && fs.existsSync(envDatei)) process.loadEnvFile(envDatei);
const args = process.argv.slice(2);
const lied = args.find((a) => !a.startsWith('--') && !['de', 'es', 'fr'].includes(a));
const wert = (n: string) => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined);
const sprache = (wert('--sprache') ?? 'de') as 'de' | 'es' | 'fr';
const neu = args.includes('--neu');
const probe = args.includes('--probe');
if (!lied) throw new Error('Lied angeben, z. B. lied01');
if (!['de', 'es', 'fr'].includes(sprache)) throw new Error(`Unbekannte Sprache: ${sprache}`);
const cfg = JSON.parse(fs.readFileSync('werkzeuge/stimmen.config.json', 'utf8')) as {
modell: string;
format: string;
stimmen: Record<string, string>;
sprachen?: Record<string, {stimmen?: Record<string, string>}>;
einstellungen?: Record<string, unknown>;
};
if (!process.env.ELEVENLABS_API_KEY && !probe) throw new Error('ELEVENLABS_API_KEY ist nicht gesetzt (siehe youtube/.env.local).');
const client = probe ? null : new ElevenLabsClient();
const {GESPROCHEN} = (await import(`../src/lieder/${lied}/gesprochen.ts`)) as {GESPROCHEN: Record<string, {id: string; wer: string; text: string}[]>};
const jsonDatei = `src/lieder/${lied}/stimmen/${sprache}.json`;
const aufnahmen: Record<string, {datei: string; dauer: number; text: string}> = fs.existsSync(jsonDatei) ? JSON.parse(fs.readFileSync(jsonDatei, 'utf8')) : {};
const ordner = `public/stimmen/lieder/${lied}/${sprache}`;
fs.mkdirSync(ordner, {recursive: true});
const stimmeVon = (wer: string): string | undefined => {
const v = cfg.sprachen?.[sprache]?.stimmen?.[wer] || cfg.stimmen[wer];
if (!v) return undefined;
return v in cfg.stimmen ? stimmeVon(v) : v;
};
const laenge = (datei: string) => {
const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', datei], {encoding: 'utf8'});
return Math.round(parseFloat(r.stdout.trim()) * 100) / 100;
};
let erzeugt = 0;
for (const z of GESPROCHEN[sprache]) {
const datei = `${z.id}.mp3`;
const pfad = path.join(ordner, datei);
const alt = aufnahmen[z.id];
if (!neu && alt && alt.text === z.text && fs.existsSync(pfad)) continue;
const stimme = stimmeVon(z.wer);
if (!stimme) throw new Error(`Keine Stimme für „${z.wer}“ in stimmen.config.json`);
if (probe) {
console.log(`~ ${z.id} (${z.wer}): ${z.text}`);
continue;
}
const audio = await client!.textToSpeech.convert(stimme, {
text: z.text,
modelId: cfg.modell,
languageCode: sprache,
outputFormat: cfg.format as never,
voiceSettings: {stability: cfg.einstellungen?.stability as number, similarityBoost: cfg.einstellungen?.similarity_boost as number},
});
const teile: Buffer[] = [];
for await (const t of Readable.fromWeb(audio as never)) teile.push(Buffer.from(t));
fs.writeFileSync(pfad, Buffer.concat(teile));
aufnahmen[z.id] = {datei: `stimmen/lieder/${lied}/${sprache}/${datei}`, dauer: laenge(pfad), text: z.text};
fs.writeFileSync(jsonDatei, JSON.stringify(aufnahmen, null, '\t') + '\n');
erzeugt++;
console.log(`✓ ${z.id} (${z.wer}) ${aufnahmen[z.id].dauer} s: ${z.text}`);
}
console.log(`\n${probe ? 'Probe' : `Erzeugt: ${erzeugt} Zeilen`}`);