youtube/remotion/werkzeuge/stimmen.mts

118 lines
5.5 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 Sprachaufnahmen einer Folge mit ElevenLabs und trägt ihre
// Länge in src/folgeNN/stimmen/<sprache>.json ein. Danach passt sich der Zeitplan der
// Szenen automatisch an die echten Längen an.
//
// ELEVENLABS_API_KEY=… npx tsx werkzeuge/stimmen.mts F02
// … F02 --szene s3 nur eine Szene
// … F02 --neu alle Zeilen neu erzeugen
// … F02 --probe nur anzeigen, was erzeugt würde (kostet nichts)
// … F02 --sprache es andere Sprache (de, es, fr; ohne Angabe de)
//
// Der Schlüssel kommt aus ELEVENLABS_API_KEY bzw. aus youtube/.env.local (angelegt mit
// `npx -y @elevenlabs/cli@latest onboard init`, nicht im Git) – nie aus dem Code.
// Stimmen-IDs und Modell stehen in werkzeuge/stimmen.config.json (je Sprache
// lassen sich dort unter „sprachen“ eigene Stimmen eintragen).
// Aufnahmen landen in public/stimmen/folgeNN/<sprache>/ (nicht im Git; auf dem VPS ein
// Link nach /shared/youtube/stimmen/). Nur Zeilen, deren Text sich geändert
// hat oder deren Datei fehlt, werden neu erzeugt.
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);
// Schlüssel aus der .env.local im Repo-Wurzelordner laden (falls nicht schon gesetzt)
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 kuerzel = args.find((a) => !a.startsWith('--'));
const wert = (n: string) => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined);
const nurSzene = wert('--szene');
const neu = args.includes('--neu');
const probe = args.includes('--probe');
const sprache = wert('--sprache') ?? 'de';
if (!['de', 'es', 'fr'].includes(sprache)) throw new Error(`Unbekannte Sprache: ${sprache}`);
if (!kuerzel) throw new Error('Folge angeben, z. B. F02');
const nummer = kuerzel.replace(/\D/g, '');
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>;
};
const schluessel = process.env.ELEVENLABS_API_KEY;
if (!schluessel && !probe) throw new Error('ELEVENLABS_API_KEY ist nicht gesetzt (siehe youtube/.env.local).');
const client = probe ? null : new ElevenLabsClient(); // liest ELEVENLABS_API_KEY
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const ausgabe = spawnSync(npx, ['tsx', 'werkzeuge/szenenliste.mts', `./src/folge${nummer}/Folge${nummer}.tsx`, kuerzel], {encoding: 'utf8', shell: process.platform === 'win32', env: {...process.env, SPRACHE: sprache}});
const szenen = JSON.parse(ausgabe.stdout.split('\n').filter((z) => z.startsWith('[')).pop()!) as {
szene?: string;
zeilen: {id: string; wer?: string; text: string; leise?: boolean}[];
}[];
const jsonDatei = `src/folge${nummer}/stimmen/${sprache}.json`;
const aufnahmen: Record<string, {datei: string; dauer: number; text: string}> = JSON.parse(fs.readFileSync(jsonDatei, 'utf8'));
const ordner = `public/stimmen/folge${nummer}/${sprache}`;
fs.mkdirSync(ordner, {recursive: true});
// Stimme je Sprecher; ein Eintrag kann auf einen anderen Sprecher verweisen (z. B. ALLE → ERZ)
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(npx, ['remotion', 'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', datei], {
encoding: 'utf8',
shell: process.platform === 'win32',
});
return Math.round(parseFloat(r.stdout.trim()) * 100) / 100;
};
let zeichen = 0;
let erzeugt = 0;
for (const s of szenen) {
if (!s.szene || (nurSzene && s.szene !== nurSzene)) continue;
for (const z of s.zeilen) {
const schl = `${s.szene}/${z.id}`;
const datei = `${s.szene}-${z.id}.mp3`;
const pfad = path.join(ordner, datei);
const alt = aufnahmen[schl];
if (!neu && alt && alt.text === z.text && fs.existsSync(pfad)) continue;
const stimme = z.wer && stimmeVon(z.wer);
if (!stimme) {
console.warn(`! ${schl}: keine Stimme für „${z.wer}“ in stimmen.config.json`);
continue;
}
zeichen += z.text.length;
if (probe) {
console.log(`~ ${schl} (${z.wer}): ${z.text}`);
continue;
}
// Sprache festlegen (bessere Aussprache); eleven_v4 kennt nur stability und similarity_boost
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[schl] = {datei, dauer: laenge(pfad), text: z.text};
fs.writeFileSync(jsonDatei, JSON.stringify(aufnahmen, null, '\t') + '\n');
erzeugt++;
console.log(`✓ ${schl} ${aufnahmen[schl].dauer} s`);
}
}
console.log(`\n${probe ? 'Würde erzeugen' : 'Erzeugt'}: ${probe ? '' : erzeugt + ' Zeilen, '}${zeichen} Zeichen`);