youtube/remotion/werkzeuge/stimmen.mts
Lara 60acaa9921 Sprechtexte nach Sprachen getrennt (DE/ES/FR), Folge 2 in allen drei Sprachen renderbar
- Texte stehen nur noch in remotion/src/folge02/texte/<sprache>.json,
  die Szenen enthalten nur Ablauf und Bewegung
- Sprache beim Rendern wählbar (render.mjs --sprache es, Studio über
  --props); Zeitplan richtet sich nach Textlänge bzw. Aufnahmen der Sprache
- Baukasten-Wörter je Sprache in baukasten/sprache.ts: Kanalname im Buch
  (mit festem Umbruch für ES/FR), „Heute: …“, Sprechernamen, Kalendertitel,
  „Hatschi!“; Kalenderbild für ES/FR vorberechnet
- Stimmen je Sprache: stimmen/<sprache>.json, public/stimmen/folge02/<sprache>/,
  stimmen.mts --sprache, eigene Stimmen je Sprache möglich
- neues werkzeuge/texte.mts: prüft fehlende Übersetzungen, --tabelle zum Gegenlesen
- README, CLAUDE.md und Drehbuch-Hinweise angepasst

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:25:52 +01:00

107 lines
4.9 KiB
TypeScript

// 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 nur aus der Umgebungsvariable, nie aus einer Datei im Repo.
// 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';
const WURZEL = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
process.chdir(WURZEL);
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.');
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;
}
const antwort = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${stimme}?output_format=${cfg.format}`, {
method: 'POST',
headers: {'xi-api-key': schluessel!, 'Content-Type': 'application/json', Accept: 'audio/mpeg'},
body: JSON.stringify({text: z.text, model_id: cfg.modell, voice_settings: cfg.einstellungen}),
});
if (!antwort.ok) throw new Error(`${schl}: ElevenLabs antwortet ${antwort.status} ${await antwort.text()}`);
fs.writeFileSync(pfad, Buffer.from(await antwort.arrayBuffer()));
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`);