- 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>
87 lines
4.2 KiB
TypeScript
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`}`);
|