// Erzeugt die gesprochenen Zeilen eines Liedvideos mit ElevenLabs (gleiche Stimmen und // Einstellungen wie werkzeuge/stimmen.mts für die Folgen) und trägt ihre Länge in // src/lieder//stimmen/.json ein. Die Zeilen stehen in src/lieder//gesprochen.ts. // // npx tsx werkzeuge/liedstimmen.mts lied01 --sprache es // … --neu alle Zeilen neu erzeugen // … --probe nur anzeigen, was erzeugt würde (kostet nichts) // // Schlüssel aus ELEVENLABS_API_KEY bzw. youtube/.env.local (nicht im Git). // Aufnahmen: public/stimmen/lieder///.mp3 (nicht im Git; auf dem VPS // liegt public/stimmen als Link auf /shared/youtube/stimmen/). 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); 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 lied = args.find((a) => !a.startsWith('--') && !['de', 'es', 'fr'].includes(a)); const wert = (n: string) => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined); const sprache = (wert('--sprache') ?? 'de') as 'de' | 'es' | 'fr'; const neu = args.includes('--neu'); const probe = args.includes('--probe'); if (!lied) throw new Error('Lied angeben, z. B. lied01'); if (!['de', 'es', 'fr'].includes(sprache)) throw new Error(`Unbekannte Sprache: ${sprache}`); const cfg = JSON.parse(fs.readFileSync('werkzeuge/stimmen.config.json', 'utf8')) as { modell: string; format: string; stimmen: Record; sprachen?: Record}>; einstellungen?: Record; }; if (!process.env.ELEVENLABS_API_KEY && !probe) throw new Error('ELEVENLABS_API_KEY ist nicht gesetzt (siehe youtube/.env.local).'); const client = probe ? null : new ElevenLabsClient(); const {GESPROCHEN} = (await import(`../src/lieder/${lied}/gesprochen.ts`)) as {GESPROCHEN: Record}; const jsonDatei = `src/lieder/${lied}/stimmen/${sprache}.json`; const aufnahmen: Record = fs.existsSync(jsonDatei) ? JSON.parse(fs.readFileSync(jsonDatei, 'utf8')) : {}; const ordner = `public/stimmen/lieder/${lied}/${sprache}`; fs.mkdirSync(ordner, {recursive: true}); 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; }; // Sprache auf −16 LUFS: deutlich vor der Suno-Musik (−14 LUFS, beim Sprechen leiser gestellt) export const lautheit = (pfad: string) => { const tmp = pfad.replace(/\.mp3$/, '.tmp.mp3'); spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', pfad, '-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-ar', '44100', '-b:a', '128k', tmp]); fs.renameSync(tmp, pfad); }; const laenge = (datei: string) => { const r = spawnSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', datei], {encoding: 'utf8'}); return Math.round(parseFloat(r.stdout.trim()) * 100) / 100; }; let erzeugt = 0; for (const z of GESPROCHEN[sprache]) { const datei = `${z.id}.mp3`; const pfad = path.join(ordner, datei); const alt = aufnahmen[z.id]; if (!neu && alt && alt.text === z.text && fs.existsSync(pfad)) continue; const stimme = stimmeVon(z.wer); if (!stimme) throw new Error(`Keine Stimme für „${z.wer}“ in stimmen.config.json`); if (probe) { console.log(`~ ${z.id} (${z.wer}): ${z.text}`); continue; } 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)); lautheit(pfad); aufnahmen[z.id] = {datei: `stimmen/lieder/${lied}/${sprache}/${datei}`, dauer: laenge(pfad), text: z.text}; fs.writeFileSync(jsonDatei, JSON.stringify(aufnahmen, null, '\t') + '\n'); erzeugt++; console.log(`✓ ${z.id} (${z.wer}) ${aufnahmen[z.id].dauer} s: ${z.text}`); } console.log(`\n${probe ? 'Probe' : `Erzeugt: ${erzeugt} Zeilen`}`);