youtube/remotion/werkzeuge/liedstimmen.mts
2026-11-05 11:10:15 +00:00

94 lines
4.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
};
// Sprache auf −16 LUFS: deutlich vor der Suno-Musik (−14 LUFS, beim Sprechen leiser gestellt)
export const lautheit = (pfad: string) => {
const tmp = pfad.replace(/\.mp3$/, '.tmp.mp3');
spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', pfad, '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-ar', '44100', '-b:a', '128k', tmp]);
fs.renameSync(tmp, pfad);
};
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));
lautheit(pfad);
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`}`);