Files
open-isp/services/voiceTtsService.js
oxpoe 1c40e6a0e8 feat(keamanan): security watchdog brute-force + soundbox TTS (port upstream)
- securityWatchdogService: deteksi brute force login admin/kasir/teknisi/kolektor & log MikroTik, blokir IP 15 menit, alert suara + WA/Telegram
- soundboxService + voiceTtsService (TTS Google Translate, cache)
- hook login admin/teknisi/kolektor, hook soundbox saat invoice lunas
- route /admin/api/soundbox/poll dan /test
2026-11-05 01:27:33 +07:00

191 lines
6.7 KiB
JavaScript

const https = require('https');
const { logger } = require('../config/logger');
// Cache audio sederhana dalam memori (maks 50 entri)
const ttsCache = new Map();
/**
* Membersihkan dan menerjemahkan format teks (markdown, emoji, simbol, istilah teknis)
* menjadi bahasa Indonesia lisan yang santun, luwes, dan natural saat dibacakan oleh mesin suara TTS.
*/
function cleanTextForSpeech(text) {
if (!text || typeof text !== 'string') return '';
let str = text;
// 1. Hapus blok kode, backtick, link markdown, html tags
str = str.replace(/```[\s\S]*?```/g, ' ');
str = str.replace(/`([^`]+)`/g, '$1');
str = str.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
str = str.replace(/<[^>]+>/g, ' ');
// 2. Terjemahkan simbol mata uang & nomor
str = str.replace(/\bRp\.?\s*([0-9.,]+)\b/gi, '$1 rupiah');
str = str.replace(/\bNo\.\s*/gi, 'nomor ');
str = str.replace(/\bNo\s+(\d+)/gi, 'nomor $1');
// 3. Terjemahkan nilai redaman optik & tanda minus angka
str = str.replace(/-(\d+(?:\.\d+)?)\s*(?:dBm|dbm|DBM)\b/gi, 'minus $1 desibel');
str = str.replace(/\b(\d+(?:\.\d+)?)\s*(?:dBm|dbm|DBM)\b/gi, '$1 desibel');
str = str.replace(/(^|\s)-(\d+(?:\.\d+)?)/g, '$1minus $2');
// 4. Terjemahkan singkatan & istilah teknis ke bahasa percakapan santun
str = str.replace(/\bRX\s*Power\b/gi, 'daya sinyal');
str = str.replace(/\bTX\s*Power\b/gi, 'daya kirim');
str = str.replace(/\bONU\b|\bONT\b/gi, 'modem');
str = str.replace(/\bPPPoE\b/gi, 'pe pe po e');
str = str.replace(/\bSSID\b/gi, 'nama wifi');
str = str.replace(/\bUptime\b/gi, 'waktu aktif');
str = str.replace(/\bIP\s*Address\b|\bIP\b/gi, 'alamat i pe');
str = str.replace(/\bDNS\b/gi, 'de en es');
str = str.replace(/\bAPI\b/gi, 'a pe i');
str = str.replace(/\bWA\b/gi, 'whatsapp');
str = str.replace(/\bQRIS\b/gi, 'kris');
str = str.replace(/\bLOS\b/gi, 'lampu merah sinyal putus');
str = str.replace(/\b(\d+)\s*Mbps\b/gi, '$1 mega bit per detik');
str = str.replace(/\b(\d+)\s*Kbps\b/gi, '$1 kilo bit per detik');
str = str.replace(/\b(\d+)\s*Gbps\b/gi, '$1 giga bit per detik');
str = str.replace(/\b(\d+)\s*ms\b/gi, '$1 mili detik');
// 5. Terjemahkan simbol umum
str = str.replace(/&/g, ' dan ');
str = str.replace(/%/g, ' persen ');
str = str.replace(/\+/g, ' plus ');
str = str.replace(/=/g, ' adalah ');
str = str.replace(/@/g, ' di ');
str = str.replace(/\//g, ' atau ');
// 6. HAPUS SEMUA EMOJI & SIMBOL GRAFIS (lingkaran warna hijau, merah, kuning, dll.)
str = str.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{200D}\u{20E3}]/gu, ' ');
str = str.replace(/[🟢🔴🟡⚪⚫🟠🔵🟣🟥🟩🟦🟧🟨⭕❌✅⚠️💡🤖🧾💳📡📶🔒🔓🔄🔑👉⏱️📱🔧🏷️💾💬⚙️🛡️💰📋🙏✔️✖️◀️▶️▲▼◆■]/gu, ' ');
// 7. Hapus markdown bullets, pembatas, dan format kaku
str = str.replace(/^[•·▪▫➢➔★☆✓✕✖\-\*]\s+/gm, '');
str = str.replace(/[•·▪▫➢➔★☆✓✕✖]/g, ' ');
str = str.replace(/^[─—\-_=]{2,}\s*$/gm, '');
str = str.replace(/[*_~#|\\^$<>\[\]{}()]/g, ' ');
// 8. Hapus semua karakter selain huruf, angka, spasi, dan tanda jeda suara (. , ? !)
str = str.replace(/[^\p{L}\p{N}\s,.\?!]/gu, ' ');
// 9. Normalisasi jeda tanda baca dan spasi
str = str.replace(/\s*([,.\?!])\s*/g, '$1 ');
str = str.replace(/,\s*,+/g, ',');
str = str.replace(/\.\s*\.+/g, '.');
str = str.replace(/\s+/g, ' ').trim();
return str;
}
/**
* Memecah teks panjang menjadi potongan kalimat (< 180 karakter)
* agar pas dengan batasan API TTS tanpa memotong kata di tengah.
*/
function splitTextIntoChunks(text, maxLen = 180) {
if (!text) return [];
const sentences = text.match(/[^.!?\n,]+[.!?\n,]*/g) || [text];
const chunks = [];
let currentChunk = '';
for (const sentence of sentences) {
const trimmed = sentence.trim();
if (!trimmed) continue;
if (trimmed.length > maxLen) {
// Jika satu kalimat saja sudah lebih dari maxLen, pecah berdasarkan kata
const words = trimmed.split(/\s+/);
for (const word of words) {
if ((currentChunk + ' ' + word).trim().length > maxLen) {
if (currentChunk.trim()) chunks.push(currentChunk.trim());
currentChunk = word;
} else {
currentChunk = (currentChunk + ' ' + word).trim();
}
}
} else if ((currentChunk + ' ' + trimmed).trim().length > maxLen) {
if (currentChunk.trim()) chunks.push(currentChunk.trim());
currentChunk = trimmed;
} else {
currentChunk = (currentChunk + ' ' + trimmed).trim();
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
}
/**
* Download satu potongan audio MP3 dari Google Translate TTS (Bahasa Indonesia)
*/
function fetchTtsChunk(chunkText, lang = 'id') {
return new Promise((resolve, reject) => {
const url = `https://translate.google.com/translate_tts?ie=UTF-8&q=${encodeURIComponent(chunkText)}&tl=${lang}&client=tw-ob`;
const req = https.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 10000
}, (res) => {
if (res.statusCode !== 200) {
return reject(new Error(`TTS gagal dengan status HTTP ${res.statusCode}`));
}
const data = [];
res.on('data', d => data.push(d));
res.on('end', () => resolve(Buffer.concat(data)));
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('TTS timeout setelah 10 detik'));
});
});
}
/**
* Generate audio MP3 utuh dari teks bahasa Indonesia
*/
async function generateIndonesianSpeech(text) {
const clean = cleanTextForSpeech(text);
if (!clean) return null;
// Cek cache
const cacheKey = clean.toLowerCase();
if (ttsCache.has(cacheKey)) {
return ttsCache.get(cacheKey);
}
const chunks = splitTextIntoChunks(clean, 175);
if (chunks.length === 0) return null;
try {
const bufferList = [];
for (const chunk of chunks) {
const buf = await fetchTtsChunk(chunk, 'id');
bufferList.push(buf);
}
const combinedBuffer = Buffer.concat(bufferList);
// Simpan di cache (maksimal 50 item)
if (ttsCache.size > 50) {
const firstKey = ttsCache.keys().next().value;
ttsCache.delete(firstKey);
}
ttsCache.set(cacheKey, combinedBuffer);
return combinedBuffer;
} catch (err) {
logger.error(`[VoiceTTS] Gagal generate suara: ${err.message}`);
throw err;
}
}
module.exports = {
cleanTextForSpeech,
splitTextIntoChunks,
generateIndonesianSpeech
};