- Columna username (unique, nullable) en auth.users - Campo username en CreateUserDto y UpdateUserDto Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
214 lines
6.4 KiB
JavaScript
Executable File
214 lines
6.4 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* Script standalone para generar audios TTS para todos los monumentos
|
|
* Usa Piper TTS directamente sin pasar por el API
|
|
*/
|
|
|
|
const { Client } = require('pg');
|
|
const { exec } = require('child_process');
|
|
const { promisify } = require('util');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// Configuración
|
|
const config = {
|
|
db: {
|
|
host: 'localhost',
|
|
port: 5432,
|
|
user: 'karibeo',
|
|
password: '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',
|
|
database: 'karibeo_db',
|
|
},
|
|
tts: {
|
|
piperPath: '/home/karibeo-api/karibeo-api/tts/piper/piper',
|
|
voicesPath: '/home/karibeo-api/karibeo-api/tts/voices',
|
|
cachePath: '/home/karibeo-api/karibeo-api/tts/cache',
|
|
baseUrl: 'https://api.karibeo.ai:8443',
|
|
},
|
|
voiceMap: {
|
|
es: 'es_ES-davefx-medium',
|
|
en: 'en_US-amy-medium',
|
|
fr: 'fr_FR-siwis-medium',
|
|
it: 'it_IT-riccardo-x_low',
|
|
de: 'de_DE-thorsten-medium',
|
|
},
|
|
languages: ['es', 'en', 'fr', 'it', 'de'],
|
|
};
|
|
|
|
// Limpiar texto para TTS
|
|
function cleanTextForSpeech(text) {
|
|
return text
|
|
.replace(/[\u4E00-\u9FFF]/g, '')
|
|
.replace(/[\u3400-\u4DBF]/g, '')
|
|
.replace(/[\u3040-\u309F]/g, '')
|
|
.replace(/[\u30A0-\u30FF]/g, '')
|
|
.replace(/[\uAC00-\uD7AF]/g, '')
|
|
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '')
|
|
.replace(/[\u{2600}-\u{26FF}]/gu, '')
|
|
.replace(/[\u{2700}-\u{27BF}]/gu, '')
|
|
.replace(/[\u{1F600}-\u{1F64F}]/gu, '')
|
|
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '')
|
|
.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '')
|
|
.replace(/[★☆✓✗✔✘●○◆◇▪▫►◄→←↑↓⇒⇐⇑⇓♠♣♥♦]/g, '')
|
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
.replace(/\*([^*]+)\*/g, '$1')
|
|
.replace(/__([^_]+)__/g, '$1')
|
|
.replace(/_([^_]+)_/g, '$1')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function getAudioHash(text, language) {
|
|
const content = `${language}:${text}`;
|
|
return crypto.createHash('md5').update(content).digest('hex');
|
|
}
|
|
|
|
async function generateAudio(text, language) {
|
|
const voice = config.voiceMap[language] || config.voiceMap['en'];
|
|
const voiceModel = path.join(config.tts.voicesPath, `${voice}.onnx`);
|
|
|
|
if (!fs.existsSync(voiceModel)) {
|
|
console.log(` ⚠️ Modelo de voz no encontrado: ${voiceModel}`);
|
|
return null;
|
|
}
|
|
|
|
const cleanText = cleanTextForSpeech(text)
|
|
.replace(/[\n\r]/g, ' ')
|
|
.replace(/"/g, "'")
|
|
.substring(0, 3000);
|
|
|
|
const hash = getAudioHash(cleanText, language);
|
|
const fileName = `${hash}.wav`;
|
|
const filePath = path.join(config.tts.cachePath, fileName);
|
|
|
|
// Si ya existe, retornar URL
|
|
if (fs.existsSync(filePath)) {
|
|
console.log(` 📦 Cache hit: ${fileName}`);
|
|
return `${config.tts.baseUrl}/api/v1/tts/audio/${fileName}`;
|
|
}
|
|
|
|
// Crear directorio si no existe
|
|
if (!fs.existsSync(config.tts.cachePath)) {
|
|
fs.mkdirSync(config.tts.cachePath, { recursive: true });
|
|
}
|
|
|
|
// Guardar texto en archivo temporal
|
|
const tempTextFile = path.join(config.tts.cachePath, `${hash}.txt`);
|
|
fs.writeFileSync(tempTextFile, cleanText, 'utf-8');
|
|
|
|
try {
|
|
const command = `cat "${tempTextFile}" | "${config.tts.piperPath}" --model "${voiceModel}" --output_file "${filePath}" 2>/dev/null`;
|
|
await execAsync(command, { timeout: 120000 });
|
|
|
|
// Limpiar archivo temporal
|
|
if (fs.existsSync(tempTextFile)) {
|
|
fs.unlinkSync(tempTextFile);
|
|
}
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
const stats = fs.statSync(filePath);
|
|
console.log(` ✅ Audio generado: ${fileName} (${Math.round(stats.size/1024)}KB)`);
|
|
return `${config.tts.baseUrl}/api/v1/tts/audio/${fileName}`;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.log(` ❌ Error TTS: ${error.message}`);
|
|
if (fs.existsSync(tempTextFile)) {
|
|
fs.unlinkSync(tempTextFile);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log('🎙️ Generador de Audios TTS para Monumentos');
|
|
console.log('==========================================\n');
|
|
|
|
// Verificar Piper
|
|
if (!fs.existsSync(config.tts.piperPath)) {
|
|
console.error(`❌ Piper no encontrado en: ${config.tts.piperPath}`);
|
|
process.exit(1);
|
|
}
|
|
console.log('✅ Piper TTS encontrado\n');
|
|
|
|
// Conectar a la DB
|
|
const client = new Client(config.db);
|
|
await client.connect();
|
|
console.log('✅ Conectado a la base de datos\n');
|
|
|
|
// Obtener lugares con descripción
|
|
const query = `
|
|
SELECT id, name, slug,
|
|
description_es, description_en, description_fr, description_it, description_de,
|
|
audio_url_es, audio_url_en, audio_url_fr, audio_url_it, audio_url_de
|
|
FROM tourism.places_of_interest
|
|
WHERE active = true
|
|
ORDER BY id
|
|
`;
|
|
|
|
const result = await client.query(query);
|
|
const places = result.rows;
|
|
console.log(`📍 Encontrados ${places.length} lugares\n`);
|
|
|
|
let totalGenerated = 0;
|
|
let totalSkipped = 0;
|
|
let totalFailed = 0;
|
|
|
|
for (let i = 0; i < places.length; i++) {
|
|
const place = places[i];
|
|
console.log(`\n[${i + 1}/${places.length}] ${place.name}`);
|
|
|
|
for (const lang of config.languages) {
|
|
const descCol = `description_${lang}`;
|
|
const audioCol = `audio_url_${lang}`;
|
|
|
|
const description = place[descCol];
|
|
const existingAudio = place[audioCol];
|
|
|
|
if (!description) {
|
|
continue;
|
|
}
|
|
|
|
if (existingAudio) {
|
|
totalSkipped++;
|
|
continue;
|
|
}
|
|
|
|
console.log(` 🔊 Generando audio (${lang})...`);
|
|
const audioUrl = await generateAudio(description, lang);
|
|
|
|
if (audioUrl) {
|
|
// Actualizar en DB
|
|
await client.query(
|
|
`UPDATE tourism.places_of_interest SET ${audioCol} = $1 WHERE id = $2`,
|
|
[audioUrl, place.id]
|
|
);
|
|
totalGenerated++;
|
|
} else {
|
|
totalFailed++;
|
|
}
|
|
|
|
// Pequeña pausa
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
}
|
|
|
|
await client.end();
|
|
|
|
console.log('\n==========================================');
|
|
console.log('📊 RESUMEN');
|
|
console.log(` ✅ Generados: ${totalGenerated}`);
|
|
console.log(` ⏭️ Omitidos (ya existían): ${totalSkipped}`);
|
|
console.log(` ❌ Fallidos: ${totalFailed}`);
|
|
console.log('==========================================\n');
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('Error fatal:', err);
|
|
process.exit(1);
|
|
});
|