// Erzeugt die Sprachaufnahmen einer Folge mit ElevenLabs und trägt ihre // Länge in src/folgeNN/stimmen/.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// (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; sprachen?: Record}>; einstellungen?: Record; }; 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 = 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`);