515 lines
15 KiB
JavaScript
515 lines
15 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const bodyParser = require('body-parser');
|
|
const axios = require('axios');
|
|
const path = require('path');
|
|
const googleTTS = require('google-tts-api');
|
|
require('dotenv').config();
|
|
|
|
const HUGGINGFACE_API_TOKEN = process.env.HUGGINGFACEHUB_API_TOKEN || process.env.HF_API_TOKEN;
|
|
const nllbLangMap = {
|
|
'en': 'eng_Latn',
|
|
'es': 'spa_Latn',
|
|
'fr': 'fra_Latn',
|
|
'de': 'deu_Latn',
|
|
'pt': 'por_Latn',
|
|
'it': 'ita_Latn',
|
|
'nl': 'nld_Latn',
|
|
'ru': 'rus_Cyrl',
|
|
'ar': 'arb_Arab',
|
|
'ja': 'jpn_Jpan',
|
|
'ko': 'kor_Kore',
|
|
'zh': 'zho_Hans',
|
|
'hi': 'hin_Deva',
|
|
'ta': 'tam_Taml',
|
|
'te': 'tel_Telu',
|
|
'ml': 'mal_Mlym',
|
|
'pa': 'pan_Guru',
|
|
'kn': 'kan_Knda',
|
|
'id': 'ind_Latn',
|
|
'vi': 'vie_Latn',
|
|
'tr': 'tur_Latn'
|
|
};
|
|
const m2m100LangMap = {
|
|
'en': 'en',
|
|
'es': 'es',
|
|
'fr': 'fr',
|
|
'de': 'de',
|
|
'pt': 'pt',
|
|
'it': 'it',
|
|
'nl': 'nl',
|
|
'ru': 'ru',
|
|
'ar': 'ar',
|
|
'ja': 'ja',
|
|
'ko': 'ko',
|
|
'zh': 'zh',
|
|
'hi': 'hi',
|
|
'ta': 'ta',
|
|
'te': 'te',
|
|
'ml': 'ml',
|
|
'pa': 'pa',
|
|
'kn': 'kn',
|
|
'id': 'id',
|
|
'vi': 'vi',
|
|
'tr': 'tr'
|
|
};
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// HTTPS setup (optional). Serve HTTPS on 3443 using provided certs or generate a self-signed cert.
|
|
const https = require('https');
|
|
const fs = require('fs');
|
|
const selfsigned = require('selfsigned');
|
|
const SSL_PORT = process.env.SSL_PORT || 3443;
|
|
|
|
function loadSslOptions() {
|
|
const keyPath = process.env.SSL_KEY_PATH;
|
|
const certPath = process.env.SSL_CERT_PATH;
|
|
|
|
if (keyPath && certPath && fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
|
return {
|
|
key: fs.readFileSync(keyPath),
|
|
cert: fs.readFileSync(certPath)
|
|
};
|
|
}
|
|
|
|
// Generate a temporary self-signed certificate for localhost
|
|
console.warn('No SSL cert/key provided. Generating a temporary self-signed certificate for https://localhost:' + SSL_PORT);
|
|
const attrs = [{ name: 'commonName', value: 'localhost' }];
|
|
const pems = selfsigned.generate(attrs, { days: 365 });
|
|
return { key: pems.private, cert: pems.cert };
|
|
}
|
|
|
|
// Middleware
|
|
app.use(cors());
|
|
app.use(bodyParser.json({ limit: '10mb' }));
|
|
app.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
|
|
app.use(bodyParser.raw({ type: 'audio/*', limit: '10mb' }));
|
|
app.use(express.static(path.join(__dirname)));
|
|
|
|
// Language code mapping
|
|
const langMap = {
|
|
'en': 'en',
|
|
'es': 'es',
|
|
'fr': 'fr',
|
|
'de': 'de',
|
|
'pt': 'pt',
|
|
'ja': 'ja',
|
|
'ko': 'ko',
|
|
'zh': 'zh-CN',
|
|
'ta': 'ta',
|
|
'te': 'te',
|
|
'ml': 'ml',
|
|
'hi': 'hi'
|
|
};
|
|
|
|
// Translation API endpoint (using free mymemory translation API)
|
|
const translateViaMyMemory = async (text, sourceLang, targetLang) => {
|
|
try {
|
|
const response = await axios.get('https://api.mymemory.translated.net/get', {
|
|
params: {
|
|
q: text,
|
|
langpair: `${sourceLang}|${targetLang}`
|
|
}
|
|
});
|
|
|
|
if (response.data.responseStatus === 200) {
|
|
return response.data.responseData.translatedText;
|
|
}
|
|
throw new Error('Translation failed');
|
|
} catch (error) {
|
|
console.error('MyMemory translation error:', error.message);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Hugging Face translation via NLLB or M2M100
|
|
const translateViaHuggingFace = async (text, sourceLang, targetLang) => {
|
|
if (!HUGGINGFACE_API_TOKEN) {
|
|
return null;
|
|
}
|
|
|
|
const isEnglishCentric = sourceLang === 'en' || targetLang === 'en';
|
|
const m2mSrcCode = m2m100LangMap[sourceLang];
|
|
const m2mTgtCode = m2m100LangMap[targetLang];
|
|
const nllbSrcCode = nllbLangMap[sourceLang];
|
|
const nllbTgtCode = nllbLangMap[targetLang];
|
|
|
|
const useM2M100 = isEnglishCentric && m2mSrcCode && m2mTgtCode;
|
|
const model = useM2M100
|
|
? 'https://api-inference.huggingface.co/models/facebook/m2m100_418M'
|
|
: 'https://api-inference.huggingface.co/models/facebook/nllb-200-distilled-600M';
|
|
const srcCode = useM2M100 ? m2mSrcCode : nllbSrcCode;
|
|
const tgtCode = useM2M100 ? m2mTgtCode : nllbTgtCode;
|
|
|
|
if (!srcCode || !tgtCode) {
|
|
return null;
|
|
}
|
|
|
|
const chosenModel = useM2M100 ? 'facebook/m2m100_418M' : 'facebook/nllb-200-distilled-600M';
|
|
console.log(`Hugging Face translation using ${useM2M100 ? 'M2M100' : 'NLLB'} model: ${chosenModel} (${srcCode} -> ${tgtCode})`);
|
|
|
|
try {
|
|
const response = await axios.post(
|
|
model,
|
|
{
|
|
inputs: text,
|
|
parameters: {
|
|
src_lang: srcCode,
|
|
tgt_lang: tgtCode
|
|
}
|
|
},
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${HUGGINGFACE_API_TOKEN}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
timeout: 20000
|
|
}
|
|
);
|
|
|
|
const data = response.data;
|
|
let resultText = null;
|
|
if (Array.isArray(data) && data[0]?.generated_text) {
|
|
resultText = data[0].generated_text;
|
|
} else if (data?.generated_text) {
|
|
resultText = data.generated_text;
|
|
} else if (typeof data === 'string') {
|
|
resultText = data;
|
|
}
|
|
|
|
return resultText ? { translatedText: resultText, model: chosenModel } : null;
|
|
} catch (error) {
|
|
console.error('Hugging Face translation error:', error.message || error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Hugging Face text-to-speech
|
|
const ttsLangMap = {
|
|
'en': 'en-US',
|
|
'es': 'es-ES',
|
|
'fr': 'fr-FR',
|
|
'de': 'de-DE',
|
|
'pt': 'pt-BR',
|
|
'it': 'it-IT',
|
|
'nl': 'nl-NL',
|
|
'ru': 'ru-RU',
|
|
'ar': 'ar-SA',
|
|
'ja': 'ja-JP',
|
|
'ko': 'ko-KR',
|
|
'zh': 'zh-CN',
|
|
'hi': 'hi-IN',
|
|
'ta': 'ta-IN',
|
|
'te': 'te-IN',
|
|
'ml': 'ml-IN',
|
|
'pa': 'pa-IN',
|
|
'kn': 'kn-IN',
|
|
'id': 'id-ID',
|
|
'vi': 'vi-VN',
|
|
'tr': 'tr-TR'
|
|
};
|
|
|
|
const speakViaHuggingFace = async (text, targetLang) => {
|
|
const normalizedLang = (targetLang || 'en').toLowerCase();
|
|
if (!HUGGINGFACE_API_TOKEN) {
|
|
return await speakViaGoogleTts(text, normalizedLang);
|
|
}
|
|
const ttsLang = ttsLangMap[normalizedLang] || 'en-US';
|
|
const preferredModels = [];
|
|
|
|
if (['ta', 'te', 'ml', 'kn', 'pa', 'hi'].includes(normalizedLang)) {
|
|
const languageModelMap = {
|
|
'ta': 'facebook/mms-tts-ta',
|
|
'te': 'facebook/mms-tts-te',
|
|
'ml': 'facebook/mms-tts-ml',
|
|
'kn': 'facebook/mms-tts-kn',
|
|
'pa': 'facebook/mms-tts-pa',
|
|
'hi': 'facebook/mms-tts-hi'
|
|
};
|
|
preferredModels.push(languageModelMap[normalizedLang]);
|
|
}
|
|
|
|
preferredModels.push(
|
|
'microsoft/speecht5_tts',
|
|
'tts_models/multilingual/multi-dataset/your_tts'
|
|
);
|
|
|
|
for (const model of preferredModels) {
|
|
try {
|
|
const payload = model.includes('mms-tts')
|
|
? { inputs: text }
|
|
: model.includes('speecht5')
|
|
? {
|
|
inputs: text,
|
|
parameters: {
|
|
language: ttsLang,
|
|
voice: 'alloy'
|
|
}
|
|
}
|
|
: {
|
|
inputs: text,
|
|
parameters: {
|
|
speaker: 'alloy',
|
|
language: ttsLang
|
|
}
|
|
};
|
|
|
|
const response = await axios.post(
|
|
`https://api-inference.huggingface.co/models/${model}`,
|
|
payload,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${HUGGINGFACE_API_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
Accept: 'audio/*'
|
|
},
|
|
responseType: 'arraybuffer',
|
|
timeout: 60000
|
|
}
|
|
);
|
|
|
|
const contentType = response.headers['content-type'] || 'audio/wav';
|
|
if (response.data && contentType.includes('audio')) {
|
|
return {
|
|
audio: Buffer.from(response.data).toString('base64'),
|
|
format: contentType.includes('mpeg') ? 'audio/mpeg' : 'audio/wav'
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Hugging Face TTS model ${model} failed:`, error.message || error);
|
|
}
|
|
}
|
|
|
|
return await speakViaGoogleTts(text, normalizedLang);
|
|
};
|
|
|
|
const speakViaGoogleTts = async (text, targetLang) => {
|
|
try {
|
|
const url = googleTTS.getAudioUrl(text, {
|
|
lang: targetLang,
|
|
slow: false,
|
|
host: 'https://translate.google.com',
|
|
splitPunctuation: '.?'
|
|
});
|
|
|
|
const response = await axios.get(url, {
|
|
responseType: 'arraybuffer',
|
|
headers: {
|
|
'User-Agent': 'Mozilla/5.0 (compatible; GoogleTTS/1.0)'
|
|
},
|
|
timeout: 20000
|
|
});
|
|
|
|
if (response.data) {
|
|
return {
|
|
audio: Buffer.from(response.data).toString('base64'),
|
|
format: 'audio/mpeg'
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn('Google TTS fallback failed:', error.message || error);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Alternative: Using LibreTranslate if available locally
|
|
const translateViaLibreTranslate = async (text, sourceLang, targetLang) => {
|
|
try {
|
|
const response = await axios.post('http://localhost:5000/translate', {
|
|
q: text,
|
|
source: sourceLang,
|
|
target: targetLang
|
|
}, {
|
|
timeout: 5000
|
|
});
|
|
return response.data.translatedText;
|
|
} catch (error) {
|
|
console.warn('LibreTranslate not available, trying MyMemory...');
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Speech-to-text endpoint using Hugging Face Whisper
|
|
app.post('/api/transcribe', async (req, res) => {
|
|
try {
|
|
const audioBuffer = req.body;
|
|
if (!audioBuffer || audioBuffer.length === 0) {
|
|
return res.status(400).json({ error: 'No audio provided' });
|
|
}
|
|
|
|
if (!HUGGINGFACE_API_TOKEN) {
|
|
return res.status(500).json({ error: 'Hugging Face token not configured' });
|
|
}
|
|
|
|
const contentType = req.headers['content-type'] || 'application/octet-stream';
|
|
const response = await axios.post(
|
|
'https://api-inference.huggingface.co/models/openai/whisper-large-v3',
|
|
audioBuffer,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${HUGGINGFACE_API_TOKEN}`,
|
|
'Content-Type': contentType
|
|
},
|
|
timeout: 60000
|
|
}
|
|
);
|
|
|
|
const data = response.data;
|
|
const transcript = Array.isArray(data) ? data[0]?.text : data?.text || '';
|
|
res.json({ transcript });
|
|
} catch (error) {
|
|
console.error('Speech-to-text error:', error.message || error);
|
|
res.status(500).json({ error: 'Speech-to-text failed' });
|
|
}
|
|
});
|
|
|
|
// Translation endpoint
|
|
app.post('/api/translate', async (req, res) => {
|
|
try {
|
|
const { text, sourceLang, targetLang } = req.body;
|
|
|
|
// Validate input
|
|
if (!text || !sourceLang || !targetLang) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
|
|
if (text.length > 5000) {
|
|
return res.status(400).json({ error: 'Text exceeds 5000 character limit' });
|
|
}
|
|
|
|
// If source and target languages are the same
|
|
if (sourceLang === targetLang) {
|
|
return res.json({ translatedText: text });
|
|
}
|
|
|
|
console.log(`Translating "${text.substring(0, 50)}..." from ${sourceLang} to ${targetLang}`);
|
|
|
|
// Try Hugging Face first if the key is configured
|
|
const hfResult = await translateViaHuggingFace(text, sourceLang, targetLang);
|
|
let translatedText = hfResult?.translatedText || null;
|
|
let translatedWith = hfResult?.model ? `Hugging Face (${hfResult.model})` : null;
|
|
|
|
// Fallback to LibreTranslate if available locally
|
|
if (!translatedText) {
|
|
translatedText = await translateViaLibreTranslate(text, sourceLang, targetLang);
|
|
translatedWith = translatedText ? 'LibreTranslate' : translatedWith;
|
|
}
|
|
|
|
// Fallback to MyMemory if other services are unavailable
|
|
if (!translatedText) {
|
|
translatedText = await translateViaMyMemory(text, sourceLang, targetLang);
|
|
translatedWith = translatedText ? 'MyMemory' : translatedWith;
|
|
}
|
|
|
|
if (!translatedText) {
|
|
return res.status(500).json({
|
|
error: 'Translation service unavailable',
|
|
translatedText: text // Fallback to original text
|
|
});
|
|
}
|
|
|
|
res.json({ translatedText, translatedWith });
|
|
} catch (error) {
|
|
console.error('Translation error:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/speak', async (req, res) => {
|
|
try {
|
|
const { text, targetLang } = req.body;
|
|
|
|
if (!text || !targetLang) {
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
|
|
const speechResult = await speakViaHuggingFace(text, targetLang);
|
|
if (!speechResult?.audio) {
|
|
return res.status(500).json({ error: 'Speech service unavailable' });
|
|
}
|
|
|
|
res.json({ audio: speechResult.audio, format: speechResult.format || 'audio/wav' });
|
|
} catch (error) {
|
|
console.error('Speech endpoint error:', error);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
}
|
|
});
|
|
|
|
// Health check endpoint
|
|
app.get('/api/health', (req, res) => {
|
|
res.json({ status: 'healthy', message: 'Translator API is running' });
|
|
});
|
|
|
|
// Get supported languages
|
|
app.get('/api/languages', (req, res) => {
|
|
const languages = {
|
|
'en': 'English',
|
|
'es': 'Spanish',
|
|
'fr': 'French',
|
|
'de': 'German',
|
|
'pt': 'Portuguese',
|
|
'it': 'Italian',
|
|
'nl': 'Dutch',
|
|
'ru': 'Russian',
|
|
'ar': 'Arabic',
|
|
'ja': 'Japanese',
|
|
'ko': 'Korean',
|
|
'zh': 'Chinese (Simplified)',
|
|
'hi': 'Hindi',
|
|
'ta': 'Tamil',
|
|
'te': 'Telugu',
|
|
'ml': 'Malayalam',
|
|
'pa': 'Punjabi',
|
|
'kn': 'Kannada',
|
|
'id': 'Indonesian',
|
|
'vi': 'Vietnamese',
|
|
'tr': 'Turkish'
|
|
};
|
|
res.json(languages);
|
|
});
|
|
|
|
// Main app page
|
|
app.get('/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'index.html'));
|
|
});
|
|
|
|
// 404 handler
|
|
app.use((req, res) => {
|
|
res.status(404).json({ error: 'Not Found' });
|
|
});
|
|
|
|
// Error handler
|
|
app.use((err, req, res, next) => {
|
|
console.error('Server error:', err);
|
|
res.status(500).json({ error: 'Internal Server Error' });
|
|
});
|
|
|
|
// Start server
|
|
app.listen(PORT, () => {
|
|
console.log(`
|
|
========================================
|
|
🌍 Language Translator Server
|
|
========================================
|
|
Server running on: http://localhost:${PORT}
|
|
|
|
Available endpoints:
|
|
- POST /api/translate - Translate text
|
|
- GET /api/languages - Get supported languages
|
|
- GET /api/health - Health check
|
|
========================================
|
|
`);
|
|
});
|
|
|
|
// Start HTTPS server on SSL_PORT
|
|
try {
|
|
const sslOptions = loadSslOptions();
|
|
https.createServer(sslOptions, app).listen(SSL_PORT, () => {
|
|
console.log(`HTTPS server running on: https://localhost:${SSL_PORT}`);
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to start HTTPS server:', err);
|
|
}
|