noiz-voice-api / index.js
Domify's picture
Update index.js
e5961fe verified
import express from 'express';
import cors from 'cors';
import fetch from 'node-fetch';
import rateLimit from 'express-rate-limit';
const app = express();
// ── CORS & Middleware (MUST come before routes) ──
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'https://domify-academy.free.nf')
.split(',')
.map(s => s.trim())
.filter(Boolean);
app.use(cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
return callback(new Error('CORS blocked'));
}
}));
app.use(express.json({ limit: '50kb' }));
app.use('/api/', rateLimit({ windowMs: 15 * 60 * 1000, max: 30 }));
// ── TTS Configuration ──
const rawKeys = process.env.NOIZ_API_KEYS || '';
const NOIZ_API_KEYS = rawKeys ? rawKeys.split(',').map(k => k.trim()).filter(Boolean) : [];
console.log(`πŸ”‘ Loaded ${NOIZ_API_KEYS.length} key(s)`);
const NOIZ_BASE = 'https://noiz.ai/v1';
const VOICE_MAP = {
'educational-male': '95814add', 'healer-serena': '5a68d66b',
'naturalist-soren': 'a845c7de', 'mentor-kai': '883b6b7c',
'mentor-maya': '0e4ab6ec', 'explainer-male': '95814add',
'narrator-female': '5a68d66b', 'robot-ai': '883b6b7b',
'dark-narrator': '883b6b7b', 'epic-narrator': '95814add'
};
const EMOTION_MAP = {
calm: { Neutral: 0.8 }, neutral: { Neutral: 0.8 }, joyful: { Joy: 0.8 },
happy: { Joy: 0.8 }, sad: { Sadness: 0.8 }, angry: { Anger: 0.8 },
surprised: { Surprise: 0.8 }, dramatic: { Anger: 0.4, Sadness: 0.4 },
excited: { Joy: 0.6, Surprise: 0.4 }, urgent: { Anger: 0.5, Surprise: 0.5 },
mysterious: { Neutral: 0.3, Surprise: 0.7 }, confident: { Joy: 0.3, Neutral: 0.7 },
fearful: { Sadness: 0.4, Surprise: 0.4 },
'😊': { Joy: 0.8 }, '😒': { Sadness: 0.8 }, '😑': { Anger: 0.8 },
'😲': { Surprise: 0.8 }, 'πŸ€”': { Neutral: 0.5 }, 'πŸ₯Ή': { Sadness: 0.5, Joy: 0.3 },
'πŸ˜”': { Sadness: 0.6 }, 'πŸ’ͺ': { Confidence: 0.8 }
};
// ── TTS Helper Functions ──
function clampNumber(value, min, max, fallback) {
const n = Number(value);
if (Number.isNaN(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
function normalizeVoiceId(tagValue) {
const v = String(tagValue || '').trim();
return VOICE_MAP[v] || v;
}
function parseCinematicScript(script, defaultVoiceId = 'mentor-kai', defaultSpeed = 1.0) {
const segments = [];
const regex = /\[([^\]]+)\]/g;
let lastIndex = 0;
let currentVoice = defaultVoiceId;
let currentSpeed = clampNumber(defaultSpeed, 0.5, 2.0, 1.0);
let currentEmotion = null;
let match;
while ((match = regex.exec(script)) !== null) {
const tagContent = match[1].trim();
const tagStart = match.index;
const tagEnd = match.index + match[0].length;
const textBeforeTag = script.slice(lastIndex, tagStart).trim();
if (textBeforeTag) {
segments.push({ type: 'speech', text: textBeforeTag, voiceId: currentVoice, speed: currentSpeed, emotion: currentEmotion });
}
const lower = tagContent.toLowerCase();
if (lower.startsWith('pause:')) {
const duration = clampNumber(lower.split(':')[1], 0, 5000, 500);
segments.push({ type: 'pause', duration });
} else if (lower.startsWith('voice:')) {
const voiceName = tagContent.slice(tagContent.indexOf(':') + 1).trim();
if (voiceName) currentVoice = normalizeVoiceId(voiceName);
} else if (lower.startsWith('speed:')) {
const speedValue = tagContent.slice(tagContent.indexOf(':') + 1).trim();
currentSpeed = clampNumber(speedValue, 0.5, 2.0, currentSpeed);
} else if (EMOTION_MAP[tagContent] || EMOTION_MAP[lower]) {
currentEmotion = EMOTION_MAP[tagContent] || EMOTION_MAP[lower];
}
lastIndex = tagEnd;
}
const tail = script.slice(lastIndex).trim();
if (tail) segments.push({ type: 'speech', text: tail, voiceId: currentVoice, speed: currentSpeed, emotion: currentEmotion });
if (segments.length === 0 && script.trim()) {
segments.push({ type: 'speech', text: script.trim(), voiceId: defaultVoiceId, speed: currentSpeed, emotion: null });
}
return segments;
}
function isAudioResponse(res) {
const ct = (res.headers.get('content-type') || '').toLowerCase();
return ct.includes('audio') || ct.includes('mpeg') || ct.includes('mp3');
}
async function callNoizTTS({ text, voiceId, speed = 1.0, emotion = null }) {
const resolvedVoiceId = normalizeVoiceId(voiceId);
const form = new URLSearchParams();
form.append('text', text);
form.append('voice_id', resolvedVoiceId);
form.append('output_format', 'mp3');
form.append('speed', String(speed));
if (emotion) {
const emo = typeof emotion === 'string' ? emotion : JSON.stringify(emotion);
form.append('emo', emo);
form.append('emotion', emo);
}
const keysToTry = [...NOIZ_API_KEYS, null];
for (const key of keysToTry) {
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'audio/mpeg' };
if (key) headers['Authorization'] = key;
try {
const res = await fetch(`${NOIZ_BASE}/text-to-speech`, { method: 'POST', headers, body: form.toString(), signal: AbortSignal.timeout(60000) });
console.log({ status: res.status, contentType: res.headers.get('content-type'), contentLength: res.headers.get('content-length'), mode: key ? 'api' : 'guest' });
if (!res.ok) {
const errText = await res.text().catch(() => '');
console.error(`❌ Noiz error ${res.status}: ${errText}`);
if (res.status === 401 && key) continue;
if (!key) continue;
throw new Error(errText || `Noiz error ${res.status}`);
}
if (!isAudioResponse(res)) {
const body = await res.text().catch(() => '');
throw new Error(`Expected audio, got: ${body.slice(0, 200)}`);
}
const audioBuffer = Buffer.from(await res.arrayBuffer());
console.log(`🎡 Audio bytes received: ${audioBuffer.length}`);
if (audioBuffer.length < 500) { console.warn('⚠️ Tiny response, skipping'); continue; }
return audioBuffer;
} catch (err) {
if (err?.name === 'AbortError') { console.warn('⏱ Request timed out'); continue; }
console.error('🌐 Fetch error:', err.message);
continue;
}
}
throw new Error('All Noiz attempts failed');
}
// ═══════════════════════════════════════════
// ISABELLA CONSULTANT (imports + routes)
// ═══════════════════════════════════════════
import { searchDuckDuckGo, scrapeSiteKnowledge, buildIsabellaPrompt, callNVIDIA } from './consultant.js';
const ISABELLA_NVIDIA_KEY = process.env.ISABELLA_NVIDIA_KEY || '';
const KNOWLEDGE_URLS = [
'https://domify-academy.free.nf/about-us',
'https://domify-academy.free.nf/product-Price',
'https://domify-academy.free.nf/refund',
'https://domify-academy.free.nf/privacy',
'https://domify-academy.free.nf'
];
let cachedKnowledge = null;
let knowledgeLastFetched = 0;
async function getKnowledge() {
const now = Date.now();
if (cachedKnowledge && (now - knowledgeLastFetched) < 30 * 60 * 1000) return cachedKnowledge;
cachedKnowledge = await scrapeSiteKnowledge(KNOWLEDGE_URLS);
knowledgeLastFetched = now;
console.log('πŸ“š Isabella knowledge base refreshed');
return cachedKnowledge;
}
app.post('/api/isabella/chat', async (req, res) => {
try {
const { message, userName, pageName, conversationHistory = [] } = req.body;
if (!message) return res.status(400).json({ error: 'Message required' });
if (!ISABELLA_NVIDIA_KEY) return res.status(500).json({ error: 'Isabella NVIDIA key not configured' });
const [knowledge, searchResults] = await Promise.all([getKnowledge(), searchDuckDuckGo(message)]);
const systemPrompt = buildIsabellaPrompt(userName || '', pageName || 'unknown', knowledge, searchResults);
const reply = await callNVIDIA(systemPrompt, message, ISABELLA_NVIDIA_KEY, conversationHistory);
let issue = null;
const issueMatch = reply.match(/\[ISSUE:\s*(.+?)\]/);
if (issueMatch) { issue = issueMatch[1]; reply = reply.replace(/\[ISSUE:.*?\]/g, '').trim(); }
res.json({ reply, issue, knowledgeRefreshed: Date.now() - knowledgeLastFetched < 60000 });
} catch (err) {
console.error('Isabella error:', err.message);
res.status(500).json({ error: 'Isabella is thinking... try again' });
}
});
app.post('/api/isabella/refresh-knowledge', async (req, res) => {
cachedKnowledge = await scrapeSiteKnowledge(KNOWLEDGE_URLS);
knowledgeLastFetched = Date.now();
res.json({ success: true, pages: cachedKnowledge.length });
});
// ═══════════════════════════════════════════
// TTS ENDPOINTS
// ═══════════════════════════════════════════
app.get('/health', (req, res) => {
res.json({ ok: true, keysLoaded: NOIZ_API_KEYS.length, service: 'Noiz Voice Studio' });
});
app.post('/api/generate-voice', async (req, res) => {
const { script, voiceId = 'mentor-kai', speed = 1.0 } = req.body || {};
if (!script || typeof script !== 'string') return res.status(400).json({ error: 'Missing script' });
if (script.length > 5000) return res.status(400).json({ error: 'Script too long (max 5000 characters)' });
try {
const segments = parseCinematicScript(script, voiceId, speed);
console.log(`πŸ“‹ Parsed ${segments.length} segment(s)`);
const outputSegments = [];
for (const seg of segments) {
if (seg.type === 'pause') { outputSegments.push({ type: 'pause', duration: seg.duration }); continue; }
console.log(`🎀 ${seg.voiceId} | speed=${seg.speed} | emotion=${seg.emotion ? 'yes' : 'no'} | text="${seg.text.slice(0, 50)}"`);
const audioBuffer = await callNoizTTS({ text: seg.text, voiceId: seg.voiceId, speed: seg.speed, emotion: seg.emotion });
outputSegments.push({ type: 'speech', voiceId: seg.voiceId, speed: seg.speed, emotion: seg.emotion, text: seg.text, audio: audioBuffer.toString('base64'), format: 'mp3' });
}
const speechCount = outputSegments.filter(s => s.type === 'speech').length;
if (speechCount === 1 && outputSegments.length === 1) {
return res.json({ mode: 'single', format: 'mp3', audio: outputSegments[0].audio, segments: outputSegments });
}
return res.json({ mode: 'timeline', format: 'mp3', segments: outputSegments });
} catch (err) {
console.error('❌ Generation failed:', err.message);
return res.status(500).json({ error: err.message });
}
});
app.listen(7860, () => { console.log('πŸš€ Cinematic Voice Studio ready'); });