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'); });