#!/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); });