- 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>
107 lines
4.9 KiB
TypeScript
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`);
|