feat: ElevenLabs-Voice-Liste + Fehlerdetails in Audio-Batch-Ergebnissen

- GET /api/tts-settings/voices/available listet die Account-Stimmen
  (Grundlage für Voice-Auswahl im CMT statt Freitext-IDs)
- Audio-Batch/-Fill-Fehler enthalten jetzt das ElevenLabs-Detail
  (z.B. voice_not_found) statt nur 'ElevenLabs error'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:00:27 +02:00
parent 985119bb03
commit f5b69a9213
4 changed files with 27 additions and 4 deletions

View File

@@ -5,7 +5,7 @@ const { query } = require('../db');
const { LANGS, fillMissingRow } = require('./translate'); const { LANGS, fillMissingRow } = require('./translate');
const { translateWordGroup } = require('./pairContent'); const { translateWordGroup } = require('./pairContent');
const { generatePairsForObject, persistPair } = require('./generatePairs'); const { generatePairsForObject, persistPair } = require('./generatePairs');
const { generateAndStore } = require('../routes/audios'); const { generateAndStore, describeError } = require('../routes/audios');
const queue = []; const queue = [];
let running = false; let running = false;
@@ -167,7 +167,7 @@ async function runPicture(pictureId) {
await generateWithBackoff(u); await generateWithBackoff(u);
progress.audiosDone++; progress.audiosDone++;
} catch (err) { } catch (err) {
failures.push(`${u.source_table}/${u.source_field}/${u.language}: ${err.message}`); failures.push(`${u.source_table}/${u.source_field}/${u.language}: ${describeError(err)}`);
} }
await setStep(pictureId, 'audio', progress); await setStep(pictureId, 'audio', progress);
} }

View File

@@ -99,6 +99,12 @@ async function generateAndStore({ text, voice_id, language, model_id, speed, sta
return result.rows[0]; return result.rows[0];
} }
// Fehlertext inkl. ElevenLabs-Detail (z.B. voice_not_found) — sonst geht die
// eigentliche Ursache in Batch-Ergebnissen als generisches 'ElevenLabs error' unter.
function describeError(err) {
return err.detail ? `${err.message}: ${String(err.detail).slice(0, 300)}` : err.message;
}
// ── Fehlende/vorhandene Audio-Einheiten für einen Filter berechnen ─────────── // ── Fehlende/vorhandene Audio-Einheiten für einen Filter berechnen ───────────
// Regel: Eine Quell-Zeile+Feld gilt erst dann als vertonbar, wenn ALLE drei Sprachen Text haben. // Regel: Eine Quell-Zeile+Feld gilt erst dann als vertonbar, wenn ALLE drei Sprachen Text haben.
// Wenn nur eine Sprache angefragt ist (Filter), wird trotzdem auf Vollständigkeit aller Sprachen geprüft. // Wenn nur eine Sprache angefragt ist (Filter), wird trotzdem auf Vollständigkeit aller Sprachen geprüft.
@@ -265,7 +271,7 @@ router.post('/generate-batch', async (req, res, next) => {
results.generated++; results.generated++;
} catch (err) { } catch (err) {
results.failed++; results.failed++;
results.errors.push({ source_id: u.source_id, field: u.source_field, lang: u.language, error: err.message }); results.errors.push({ source_id: u.source_id, field: u.source_field, lang: u.language, error: describeError(err) });
} }
} }
res.json(results); res.json(results);
@@ -318,3 +324,4 @@ router.delete('/:id', async (req, res, next) => {
module.exports = router; module.exports = router;
// Für lib/pipeline.js (Audio-Generierung außerhalb des HTTP-Kontexts) // Für lib/pipeline.js (Audio-Generierung außerhalb des HTTP-Kontexts)
module.exports.generateAndStore = generateAndStore; module.exports.generateAndStore = generateAndStore;
module.exports.describeError = describeError;

View File

@@ -4,6 +4,7 @@ const { query } = require('../db');
const { LANGS } = require('../lib/translate'); const { LANGS } = require('../lib/translate');
const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent'); const { loadPairContext, computeReadiness, loadPairContent } = require('../lib/pairContent');
const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff, translatePair } = require('../lib/pipeline'); const { enqueue, loadPairs, collectAudioUnits, generateWithBackoff, translatePair } = require('../lib/pipeline');
const { describeError } = require('./audios');
const { PLACEHOLDER_RE } = require('../lib/placeholders'); const { PLACEHOLDER_RE } = require('../lib/placeholders');
// ── Objekt-Wort-Erkennung in Sätzen (für die manuelle Zuweisung beim Review) ── // ── Objekt-Wort-Erkennung in Sätzen (für die manuelle Zuweisung beim Review) ──
@@ -233,7 +234,7 @@ router.post('/picture/:id/audio-fill', async (req, res, next) => {
try { await generateWithBackoff(u); result.generated++; } try { await generateWithBackoff(u); result.generated++; }
catch (err) { catch (err) {
result.failed++; result.failed++;
result.errors.push({ source: `${u.source_table}/${u.source_field}/${u.language}`, error: err.message }); result.errors.push({ source: `${u.source_table}/${u.source_field}/${u.language}`, error: describeError(err) });
} }
} }
res.json(result); res.json(result);

View File

@@ -3,6 +3,21 @@ const { query } = require('../db');
const EDITABLE = ['voice_id', 'model_id', 'speed', 'stability', 'similarity_boost', 'style']; const EDITABLE = ['voice_id', 'model_id', 'speed', 'stability', 'similarity_boost', 'style'];
// GET /api/tts-settings/voices/available — Stimmen des ElevenLabs-Accounts.
// Muss vor GET /:language registriert sein, sonst matcht ':language' den Pfad.
router.get('/voices/available', async (req, res, next) => {
try {
const apiKey = process.env.ELEVENLABS_API_KEY;
if (!apiKey) return res.status(500).json({ error: 'ELEVENLABS_API_KEY not configured' });
const r = await fetch('https://api.elevenlabs.io/v1/voices', { headers: { 'xi-api-key': apiKey } });
if (!r.ok) return res.status(r.status).json({ error: 'ElevenLabs error', detail: await r.text() });
const data = await r.json();
res.json((data.voices || []).map(v => ({
voice_id: v.voice_id, name: v.name, labels: v.labels || {}, preview_url: v.preview_url || null,
})));
} catch (err) { next(err); }
});
// GET /api/tts-settings // GET /api/tts-settings
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {