Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MyLusGH2U5AudjoARey3N
118 lines
5.5 KiB
TypeScript
118 lines
5.5 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 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`);
|