feat: TTS-Settings je Sprache, Audio-Coverage entkoppelt, Veröffentlichen-Workflow
- tts_settings (voice/model/speed/... pro Sprache) + Seed de/en/sv; Route /api/tts-settings - audios: Stimme/Parameter aus tts_settings; Coverage zählt jetzt auch draft/translated - pairs: GET /publishability (Readiness, sortierbar nach 'am wenigsten fehlt'), POST /:id/publish (kaskadiert question/statements→published, validiert Bild+Audio je Sprache) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -572,6 +572,34 @@ async function migrate() {
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
|
||||
`);
|
||||
|
||||
// tts_settings — Stimme + Parameter pro Sprache (zentral konfigurierbar)
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS tts_settings (
|
||||
language VARCHAR(10) PRIMARY KEY,
|
||||
voice_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL DEFAULT 'eleven_multilingual_v2',
|
||||
speed NUMERIC(4,2) NOT NULL DEFAULT 1.0,
|
||||
stability NUMERIC(4,2) NOT NULL DEFAULT 0.5,
|
||||
similarity_boost NUMERIC(4,2) NOT NULL DEFAULT 0.75,
|
||||
style NUMERIC(4,2) NOT NULL DEFAULT 0.0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
await query(`
|
||||
DROP TRIGGER IF EXISTS tts_settings_updated_at ON tts_settings;
|
||||
CREATE TRIGGER tts_settings_updated_at
|
||||
BEFORE UPDATE ON tts_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
|
||||
`);
|
||||
// Seed-Stimmen (nur einfügen wenn fehlt — manuelle Änderungen bleiben erhalten)
|
||||
await query(`
|
||||
INSERT INTO tts_settings (language, voice_id) VALUES
|
||||
('de', 'rKiu7lQ4c5P3az3745s3'),
|
||||
('en', 'cVd39cx0VtXNC13y5Y7z'),
|
||||
('sv', 'XXCqsM8I9KhqA7jLGj1U')
|
||||
ON CONFLICT (language) DO NOTHING
|
||||
`).catch(() => {});
|
||||
|
||||
// ── Migrate old {{uuid}} placeholders → new {{label.w:uuid}} / {{label.o:uuid}} ──
|
||||
await migratePlaceholders();
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ app.use('/api/user-names', auth, require('./routes/user-names'));
|
||||
app.use('/api/users-public', auth, require('./routes/users-public'));
|
||||
app.use('/api/users', auth, require('./routes/users'));
|
||||
app.use('/api/audios', auth, require('./routes/audios'));
|
||||
app.use('/api/tts-settings', auth, require('./routes/tts-settings'));
|
||||
app.use('/api/claude', auth, require('./routes/claude'));
|
||||
|
||||
// 404
|
||||
|
||||
@@ -8,23 +8,43 @@ const ELEVENLABS_BASE = 'https://api.elevenlabs.io/v1';
|
||||
const ALLOWED_STATUSES = ['generated', 'published', 'blocked'];
|
||||
const LANGS = ['de', 'en', 'sv'];
|
||||
|
||||
// Welche Felder pro Quelle Audio brauchen und ab welchem Status sie "reif" sind.
|
||||
// Welche Felder pro Quelle Audio brauchen und ab welchem Status sie vertonbar sind.
|
||||
// Audio darf früh erzeugt werden (sobald Text existiert, nur 'blocked' ausgenommen) —
|
||||
// das ist entkoppelt vom App-Gate (published).
|
||||
// column = `${field}_${lang}` (z.B. titel_de, sentence_sv, positive_sentence_en)
|
||||
const SOURCE_CONFIG = {
|
||||
words: { fields: ['titel'], ready: ['generated', 'published'] },
|
||||
questions: { fields: ['sentence'], ready: ['reviewed', 'published'] },
|
||||
statements: { fields: ['positive_sentence', 'negative_sentence'], ready: ['reviewed', 'published'] },
|
||||
words: { fields: ['titel'], ready: ['translated', 'generated', 'published'] },
|
||||
questions: { fields: ['sentence'], ready: ['draft', 'reviewed', 'published'] },
|
||||
statements: { fields: ['positive_sentence', 'negative_sentence'], ready: ['draft', 'reviewed', 'published'] },
|
||||
};
|
||||
|
||||
// Stimme + Parameter pro Sprache aus tts_settings (Fallback: env-Voice aus voices.js)
|
||||
async function getTtsSettings(language) {
|
||||
const r = await query('SELECT * FROM tts_settings WHERE language = $1', [language]);
|
||||
if (r.rows.length) {
|
||||
const s = r.rows[0];
|
||||
return {
|
||||
voice_id: s.voice_id,
|
||||
model_id: s.model_id || 'eleven_multilingual_v2',
|
||||
speed: Number(s.speed), stability: Number(s.stability),
|
||||
similarity_boost: Number(s.similarity_boost), style: Number(s.style),
|
||||
};
|
||||
}
|
||||
return { voice_id: voiceForLanguage(language), model_id: 'eleven_multilingual_v2',
|
||||
speed: 1.0, stability: 0.5, similarity_boost: 0.75, style: 0.0 };
|
||||
}
|
||||
|
||||
// ── ElevenLabs aufrufen + in S3/DB ablegen ──────────────────────────────────
|
||||
async function generateAndStore({ text, voice_id, language, model_id, speed, stability,
|
||||
similarity_boost, style, source_table, source_id, source_field }) {
|
||||
const apiKey = process.env.ELEVENLABS_API_KEY;
|
||||
if (!apiKey) { const e = new Error('ELEVENLABS_API_KEY not configured'); e.status = 500; throw e; }
|
||||
|
||||
const voice = voice_id || voiceForLanguage(language);
|
||||
const m = model_id || 'eleven_multilingual_v2';
|
||||
const sp = speed ?? 1.0, st = stability ?? 0.5, sb = similarity_boost ?? 0.75, sy = style ?? 0.0;
|
||||
const cfg = await getTtsSettings(language);
|
||||
const voice = voice_id || cfg.voice_id;
|
||||
const m = model_id || cfg.model_id;
|
||||
const sp = speed ?? cfg.speed, st = stability ?? cfg.stability,
|
||||
sb = similarity_boost ?? cfg.similarity_boost, sy = style ?? cfg.style;
|
||||
|
||||
const elevenRes = await fetch(
|
||||
`${ELEVENLABS_BASE}/text-to-speech/${voice}/with-timestamps`,
|
||||
|
||||
@@ -15,6 +15,116 @@ const STATUS_TIMESTAMP = {
|
||||
blocked: 'blocked_at',
|
||||
};
|
||||
|
||||
const LANGS = ['de', 'en', 'sv'];
|
||||
|
||||
// Sammelt für eine Menge Pairs den Kontext (Fragen, Statements, Bilder, Audios) für eine Sprache.
|
||||
async function loadPairContext(pairs, lang) {
|
||||
const questionIds = [...new Set(pairs.map(p => p.question_id).filter(Boolean))];
|
||||
const statementIds = [...new Set([
|
||||
...pairs.map(p => p.positive_statement_id),
|
||||
...pairs.map(p => p.negative_statement_id),
|
||||
].filter(Boolean))];
|
||||
const pairIds = pairs.map(p => p.id);
|
||||
|
||||
const questionsMap = {}, statementsMap = {}, pictureMap = {}, audioMap = {};
|
||||
|
||||
if (questionIds.length) {
|
||||
const r = await query(
|
||||
`SELECT id, status, sentence_${lang} AS sentence FROM questions WHERE id = ANY($1)`, [questionIds]);
|
||||
r.rows.forEach(q => { questionsMap[q.id] = q; });
|
||||
}
|
||||
if (statementIds.length) {
|
||||
const r = await query(
|
||||
`SELECT id, status, positive_sentence_${lang} AS positive, negative_sentence_${lang} AS negative
|
||||
FROM statements WHERE id = ANY($1)`, [statementIds]);
|
||||
r.rows.forEach(s => { statementsMap[s.id] = s; });
|
||||
}
|
||||
if (pairIds.length) {
|
||||
const r = await query(
|
||||
`SELECT op.pair_id,
|
||||
bool_or(true) AS has_picture,
|
||||
bool_or(pp.status = 'published') AS has_published_picture
|
||||
FROM object_pairs op
|
||||
JOIN object_pictures pic ON pic.object_id = op.object_id
|
||||
JOIN pictures pp ON pp.id = pic.picture_id
|
||||
WHERE op.pair_id = ANY($1)
|
||||
GROUP BY op.pair_id`, [pairIds]);
|
||||
r.rows.forEach(row => { pictureMap[row.pair_id] = row; });
|
||||
|
||||
const ids = [...questionIds, ...statementIds];
|
||||
if (ids.length) {
|
||||
const a = await query(
|
||||
`SELECT source_table, source_id, source_field FROM audios
|
||||
WHERE source_id = ANY($1) AND language = $2 AND status <> 'blocked'`, [ids, lang]);
|
||||
a.rows.forEach(x => { audioMap[`${x.source_table}|${x.source_id}|${x.source_field}`] = true; });
|
||||
}
|
||||
}
|
||||
return { questionsMap, statementsMap, pictureMap, audioMap };
|
||||
}
|
||||
|
||||
// Berechnet, was einem Pair zur Veröffentlichung (für eine Sprache) noch fehlt.
|
||||
function computeReadiness(p, ctx, lang) {
|
||||
const missing = [];
|
||||
const q = p.question_id ? ctx.questionsMap[p.question_id] : null;
|
||||
const ps = p.positive_statement_id ? ctx.statementsMap[p.positive_statement_id] : null;
|
||||
const ns = p.negative_statement_id ? ctx.statementsMap[p.negative_statement_id] : null;
|
||||
const pic = ctx.pictureMap[p.id];
|
||||
|
||||
// Bild
|
||||
if (!pic || !pic.has_picture) missing.push('Bild fehlt');
|
||||
else if (!pic.has_published_picture) missing.push('Bild nicht veröffentlicht');
|
||||
|
||||
// Frage
|
||||
if (q) {
|
||||
if (!(q.sentence || '').trim()) missing.push(`Frage-Text (${lang}) fehlt`);
|
||||
else {
|
||||
if (q.status !== 'published') missing.push('Frage nicht freigegeben');
|
||||
if (!ctx.audioMap[`questions|${p.question_id}|sentence`]) missing.push('Audio Frage fehlt');
|
||||
}
|
||||
}
|
||||
// Positiv-Statement
|
||||
if (ps) {
|
||||
if (!(ps.positive || '').trim()) missing.push(`Positiv-Satz (${lang}) fehlt`);
|
||||
else {
|
||||
if (ps.status !== 'published') missing.push('Positiv-Satz nicht freigegeben');
|
||||
if (!ctx.audioMap[`statements|${p.positive_statement_id}|positive_sentence`]) missing.push('Audio Positiv fehlt');
|
||||
}
|
||||
}
|
||||
// Negativ-Statement (nur wenn Text vorhanden)
|
||||
if (ns && (ns.negative || '').trim()) {
|
||||
if (ns.status !== 'published') missing.push('Negativ-Satz nicht freigegeben');
|
||||
if (!ctx.audioMap[`statements|${p.negative_statement_id}|negative_sentence`]) missing.push('Audio Negativ fehlt');
|
||||
}
|
||||
|
||||
return { missing, missingCount: missing.length, ready: missing.length === 0 };
|
||||
}
|
||||
|
||||
// GET /api/pairs/publishability?lang=sv — Pairs mit Readiness, sortierbar nach "am wenigsten fehlt"
|
||||
router.get('/publishability', async (req, res, next) => {
|
||||
try {
|
||||
const lang = LANGS.includes(req.query.lang) ? req.query.lang : 'de';
|
||||
const includePublished = req.query.includePublished === 'true';
|
||||
const statusFilter = includePublished ? `status <> 'blocked'` : `status IN ('draft','reviewed')`;
|
||||
const pairsRes = await query(
|
||||
`SELECT id, answer_type, status, question_id, positive_statement_id, negative_statement_id
|
||||
FROM pairs WHERE ${statusFilter} ORDER BY created_at DESC LIMIT 300`);
|
||||
const pairs = pairsRes.rows;
|
||||
if (!pairs.length) return res.json({ lang, pairs: [] });
|
||||
|
||||
const ctx = await loadPairContext(pairs, lang);
|
||||
const result = pairs.map(p => {
|
||||
const r = computeReadiness(p, ctx, lang);
|
||||
const q = p.question_id ? ctx.questionsMap[p.question_id] : null;
|
||||
const ps = p.positive_statement_id ? ctx.statementsMap[p.positive_statement_id] : null;
|
||||
const preview = (q?.sentence || ps?.positive || '').slice(0, 80);
|
||||
return { id: p.id, answer_type: p.answer_type, status: p.status, preview,
|
||||
missing: r.missing, missingCount: r.missingCount, ready: r.ready };
|
||||
}).sort((a, b) => a.missingCount - b.missingCount);
|
||||
|
||||
res.json({ lang, pairs: result });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/pairs
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
@@ -104,6 +214,37 @@ router.patch('/:id', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// POST /api/pairs/:id/publish?lang=sv — validiert Readiness und veröffentlicht kaskadierend
|
||||
router.post('/:id/publish', async (req, res, next) => {
|
||||
try {
|
||||
const lang = LANGS.includes(req.query.lang || req.body.lang) ? (req.query.lang || req.body.lang) : 'de';
|
||||
const pr = await query(
|
||||
`SELECT id, answer_type, status, question_id, positive_statement_id, negative_statement_id
|
||||
FROM pairs WHERE id = $1`, [req.params.id]);
|
||||
if (!pr.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
const p = pr.rows[0];
|
||||
|
||||
const ctx = await loadPairContext([p], lang);
|
||||
const r = computeReadiness(p, ctx, lang);
|
||||
if (!r.ready)
|
||||
return res.status(409).json({ error: 'Noch nicht veröffentlichbar', missing: r.missing, lang });
|
||||
|
||||
// Kaskadierend veröffentlichen
|
||||
const now = new Date().toISOString();
|
||||
const ids = [p.question_id, p.positive_statement_id, p.negative_statement_id].filter(Boolean);
|
||||
if (p.question_id)
|
||||
await query(`UPDATE questions SET status='published', published_at=COALESCE(published_at,$2) WHERE id=$1`, [p.question_id, now]);
|
||||
const stmtIds = [p.positive_statement_id, p.negative_statement_id].filter(Boolean);
|
||||
if (stmtIds.length)
|
||||
await query(`UPDATE statements SET status='published', published_at=COALESCE(published_at,$2) WHERE id = ANY($1)`, [stmtIds, now]);
|
||||
const upd = await query(
|
||||
`UPDATE pairs SET status='published', published_at=COALESCE(published_at,$2) WHERE id=$1 RETURNING *`,
|
||||
[p.id, now]);
|
||||
|
||||
res.json({ ...upd.rows[0], published_languages: [lang] });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// DELETE /api/pairs/:id
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
|
||||
58
src/routes/tts-settings.js
Normal file
58
src/routes/tts-settings.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const router = require('express').Router();
|
||||
const { query } = require('../db');
|
||||
|
||||
const EDITABLE = ['voice_id', 'model_id', 'speed', 'stability', 'similarity_boost', 'style'];
|
||||
|
||||
// GET /api/tts-settings
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const r = await query('SELECT * FROM tts_settings ORDER BY language');
|
||||
res.json(r.rows);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// GET /api/tts-settings/:language
|
||||
router.get('/:language', async (req, res, next) => {
|
||||
try {
|
||||
const r = await query('SELECT * FROM tts_settings WHERE language = $1', [req.params.language]);
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PUT /api/tts-settings/:language — anlegen oder aktualisieren
|
||||
router.put('/:language', async (req, res, next) => {
|
||||
try {
|
||||
const { voice_id } = req.body;
|
||||
if (!voice_id) return res.status(400).json({ error: 'voice_id is required' });
|
||||
const r = await query(
|
||||
`INSERT INTO tts_settings (language, voice_id, model_id, speed, stability, similarity_boost, style)
|
||||
VALUES ($1, $2, COALESCE($3,'eleven_multilingual_v2'), COALESCE($4,1.0), COALESCE($5,0.5), COALESCE($6,0.75), COALESCE($7,0.0))
|
||||
ON CONFLICT (language) DO UPDATE SET
|
||||
voice_id = EXCLUDED.voice_id, model_id = EXCLUDED.model_id, speed = EXCLUDED.speed,
|
||||
stability = EXCLUDED.stability, similarity_boost = EXCLUDED.similarity_boost, style = EXCLUDED.style
|
||||
RETURNING *`,
|
||||
[req.params.language, voice_id, req.body.model_id, req.body.speed,
|
||||
req.body.stability, req.body.similarity_boost, req.body.style]
|
||||
);
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// PATCH /api/tts-settings/:language
|
||||
router.patch('/:language', async (req, res, next) => {
|
||||
try {
|
||||
const fields = Object.keys(req.body).filter(k => EDITABLE.includes(k));
|
||||
if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' });
|
||||
const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', ');
|
||||
const values = [...fields.map(f => req.body[f]), req.params.language];
|
||||
const r = await query(
|
||||
`UPDATE tts_settings SET ${setClauses} WHERE language = $${fields.length + 1} RETURNING *`,
|
||||
values
|
||||
);
|
||||
if (!r.rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(r.rows[0]);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user