Spaces:
Running
Running
Update index.js
Browse files
index.js
CHANGED
|
@@ -2,19 +2,172 @@ import express from 'express';
|
|
| 2 |
import cors from 'cors';
|
| 3 |
import fetch from 'node-fetch';
|
| 4 |
import rateLimit from 'express-rate-limit';
|
|
|
|
| 5 |
const app = express();
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import { searchDuckDuckGo, scrapeSiteKnowledge, buildIsabellaPrompt, callNVIDIA } from './consultant.js';
|
| 8 |
|
| 9 |
const ISABELLA_NVIDIA_KEY = process.env.ISABELLA_NVIDIA_KEY || '';
|
| 10 |
|
| 11 |
-
// URLs to scrape for knowledge
|
| 12 |
const KNOWLEDGE_URLS = [
|
| 13 |
'https://domify-academy.free.nf/about-us',
|
| 14 |
-
'domify-academy.free.nf/product-Price',
|
| 15 |
'https://domify-academy.free.nf/refund',
|
| 16 |
-
|
| 17 |
-
|
| 18 |
];
|
| 19 |
|
| 20 |
let cachedKnowledge = null;
|
|
@@ -22,9 +175,7 @@ let knowledgeLastFetched = 0;
|
|
| 22 |
|
| 23 |
async function getKnowledge() {
|
| 24 |
const now = Date.now();
|
| 25 |
-
if (cachedKnowledge && (now - knowledgeLastFetched) < 30 * 60 * 1000)
|
| 26 |
-
return cachedKnowledge;
|
| 27 |
-
}
|
| 28 |
cachedKnowledge = await scrapeSiteKnowledge(KNOWLEDGE_URLS);
|
| 29 |
knowledgeLastFetched = now;
|
| 30 |
console.log('π Isabella knowledge base refreshed');
|
|
@@ -34,371 +185,63 @@ async function getKnowledge() {
|
|
| 34 |
app.post('/api/isabella/chat', async (req, res) => {
|
| 35 |
try {
|
| 36 |
const { message, userName, pageName, conversationHistory = [] } = req.body;
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
if (!ISABELLA_NVIDIA_KEY) {
|
| 43 |
-
return res.status(500).json({ error: 'Isabella NVIDIA key not configured' });
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
// Get knowledge and search in parallel
|
| 47 |
-
const [knowledge, searchResults] = await Promise.all([
|
| 48 |
-
getKnowledge(),
|
| 49 |
-
searchDuckDuckGo(message)
|
| 50 |
-
]);
|
| 51 |
-
|
| 52 |
-
// Build enriched system prompt
|
| 53 |
-
const systemPrompt = buildIsabellaPrompt(
|
| 54 |
-
userName || '',
|
| 55 |
-
pageName || 'unknown',
|
| 56 |
-
knowledge,
|
| 57 |
-
searchResults
|
| 58 |
-
);
|
| 59 |
-
|
| 60 |
-
// Call NVIDIA for actual response
|
| 61 |
const reply = await callNVIDIA(systemPrompt, message, ISABELLA_NVIDIA_KEY, conversationHistory);
|
| 62 |
|
| 63 |
-
// Check for embedded issue reports
|
| 64 |
let issue = null;
|
| 65 |
const issueMatch = reply.match(/\[ISSUE:\s*(.+?)\]/);
|
| 66 |
-
if (issueMatch) {
|
| 67 |
-
issue = issueMatch[1];
|
| 68 |
-
reply = reply.replace(/\[ISSUE:.*?\]/g, '').trim();
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
res.json({
|
| 72 |
-
reply,
|
| 73 |
-
issue,
|
| 74 |
-
knowledgeRefreshed: Date.now() - knowledgeLastFetched < 60000
|
| 75 |
-
});
|
| 76 |
|
|
|
|
| 77 |
} catch (err) {
|
| 78 |
console.error('Isabella error:', err.message);
|
| 79 |
res.status(500).json({ error: 'Isabella is thinking... try again' });
|
| 80 |
}
|
| 81 |
});
|
| 82 |
|
| 83 |
-
// ββ Knowledge refresh ββββββββββββββββββββββββββββ
|
| 84 |
app.post('/api/isabella/refresh-knowledge', async (req, res) => {
|
| 85 |
cachedKnowledge = await scrapeSiteKnowledge(KNOWLEDGE_URLS);
|
| 86 |
knowledgeLastFetched = Date.now();
|
| 87 |
res.json({ success: true, pages: cachedKnowledge.length });
|
| 88 |
});
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
.split(',')
|
| 94 |
-
.map(s => s.trim())
|
| 95 |
-
.filter(Boolean);
|
| 96 |
-
|
| 97 |
-
app.use(cors({
|
| 98 |
-
origin: (origin, callback) => {
|
| 99 |
-
if (!origin) return callback(null, true); // allow server-to-server / tools
|
| 100 |
-
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
|
| 101 |
-
return callback(new Error('CORS blocked'));
|
| 102 |
-
}
|
| 103 |
-
}));
|
| 104 |
-
|
| 105 |
-
app.use(express.json({ limit: '50kb' }));
|
| 106 |
-
|
| 107 |
-
app.use('/api/', rateLimit({
|
| 108 |
-
windowMs: 15 * 60 * 1000,
|
| 109 |
-
max: 30
|
| 110 |
-
}));
|
| 111 |
-
|
| 112 |
-
const rawKeys = process.env.NOIZ_API_KEYS || '';
|
| 113 |
-
const NOIZ_API_KEYS = rawKeys
|
| 114 |
-
? rawKeys.split(',').map(k => k.trim()).filter(Boolean)
|
| 115 |
-
: [];
|
| 116 |
-
|
| 117 |
-
console.log(`π Loaded ${NOIZ_API_KEYS.length} key(s)`);
|
| 118 |
-
|
| 119 |
-
const NOIZ_BASE = 'https://noiz.ai/v1';
|
| 120 |
-
|
| 121 |
-
const VOICE_MAP = {
|
| 122 |
-
'educational-male': '95814add',
|
| 123 |
-
'healer-serena': '5a68d66b',
|
| 124 |
-
'naturalist-soren': 'a845c7de',
|
| 125 |
-
'mentor-kai': '883b6b7c',
|
| 126 |
-
'mentor-maya': '0e4ab6ec',
|
| 127 |
-
'explainer-male': '95814add',
|
| 128 |
-
'narrator-female': '5a68d66b',
|
| 129 |
-
'robot-ai': '883b6b7b', // if you have a confirmed ID, replace this
|
| 130 |
-
'dark-narrator': '883b6b7b',
|
| 131 |
-
'epic-narrator': '95814add'
|
| 132 |
-
};
|
| 133 |
-
|
| 134 |
-
const EMOTION_MAP = {
|
| 135 |
-
calm: { Neutral: 0.8 },
|
| 136 |
-
neutral: { Neutral: 0.8 },
|
| 137 |
-
joyful: { Joy: 0.8 },
|
| 138 |
-
happy: { Joy: 0.8 },
|
| 139 |
-
sad: { Sadness: 0.8 },
|
| 140 |
-
angry: { Anger: 0.8 },
|
| 141 |
-
surprised: { Surprise: 0.8 },
|
| 142 |
-
dramatic: { Anger: 0.4, Sadness: 0.4 },
|
| 143 |
-
excited: { Joy: 0.6, Surprise: 0.4 },
|
| 144 |
-
urgent: { Anger: 0.5, Surprise: 0.5 },
|
| 145 |
-
mysterious: { Neutral: 0.3, Surprise: 0.7 },
|
| 146 |
-
confident: { Joy: 0.3, Neutral: 0.7 },
|
| 147 |
-
fearful: { Sadness: 0.4, Surprise: 0.4 },
|
| 148 |
-
"π": { Joy: 0.8 },
|
| 149 |
-
"π’": { Sadness: 0.8 },
|
| 150 |
-
"π‘": { Anger: 0.8 },
|
| 151 |
-
"π²": { Surprise: 0.8 },
|
| 152 |
-
"π€": { Neutral: 0.5 },
|
| 153 |
-
"π₯Ή": { Sadness: 0.5, Joy: 0.3 },
|
| 154 |
-
"π": { Sadness: 0.6 },
|
| 155 |
-
"πͺ": { Confidence: 0.8 }
|
| 156 |
-
};
|
| 157 |
-
|
| 158 |
-
function clampNumber(value, min, max, fallback) {
|
| 159 |
-
const n = Number(value);
|
| 160 |
-
if (Number.isNaN(n)) return fallback;
|
| 161 |
-
return Math.max(min, Math.min(max, n));
|
| 162 |
-
}
|
| 163 |
-
|
| 164 |
-
function normalizeVoiceId(tagValue) {
|
| 165 |
-
const v = String(tagValue || '').trim();
|
| 166 |
-
return VOICE_MAP[v] || v;
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
function parseCinematicScript(script, defaultVoiceId = 'mentor-kai', defaultSpeed = 1.0) {
|
| 170 |
-
const segments = [];
|
| 171 |
-
const regex = /\[([^\]]+)\]/g;
|
| 172 |
-
|
| 173 |
-
let lastIndex = 0;
|
| 174 |
-
let currentVoice = defaultVoiceId;
|
| 175 |
-
let currentSpeed = clampNumber(defaultSpeed, 0.5, 2.0, 1.0);
|
| 176 |
-
let currentEmotion = null;
|
| 177 |
-
|
| 178 |
-
let match;
|
| 179 |
-
while ((match = regex.exec(script)) !== null) {
|
| 180 |
-
const tagContent = match[1].trim();
|
| 181 |
-
const tagStart = match.index;
|
| 182 |
-
const tagEnd = match.index + match[0].length;
|
| 183 |
-
|
| 184 |
-
const textBeforeTag = script.slice(lastIndex, tagStart).trim();
|
| 185 |
-
if (textBeforeTag) {
|
| 186 |
-
segments.push({
|
| 187 |
-
type: 'speech',
|
| 188 |
-
text: textBeforeTag,
|
| 189 |
-
voiceId: currentVoice,
|
| 190 |
-
speed: currentSpeed,
|
| 191 |
-
emotion: currentEmotion
|
| 192 |
-
});
|
| 193 |
-
}
|
| 194 |
-
|
| 195 |
-
const lower = tagContent.toLowerCase();
|
| 196 |
-
|
| 197 |
-
if (lower.startsWith('pause:')) {
|
| 198 |
-
const duration = clampNumber(lower.split(':')[1], 0, 5000, 500);
|
| 199 |
-
segments.push({
|
| 200 |
-
type: 'pause',
|
| 201 |
-
duration
|
| 202 |
-
});
|
| 203 |
-
} else if (lower.startsWith('voice:')) {
|
| 204 |
-
const voiceName = tagContent.slice(tagContent.indexOf(':') + 1).trim();
|
| 205 |
-
if (voiceName) {
|
| 206 |
-
currentVoice = normalizeVoiceId(voiceName);
|
| 207 |
-
}
|
| 208 |
-
} else if (lower.startsWith('speed:')) {
|
| 209 |
-
const speedValue = tagContent.slice(tagContent.indexOf(':') + 1).trim();
|
| 210 |
-
currentSpeed = clampNumber(speedValue, 0.5, 2.0, currentSpeed);
|
| 211 |
-
} else if (EMOTION_MAP[tagContent] || EMOTION_MAP[lower]) {
|
| 212 |
-
currentEmotion = EMOTION_MAP[tagContent] || EMOTION_MAP[lower];
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
lastIndex = tagEnd;
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
const tail = script.slice(lastIndex).trim();
|
| 219 |
-
if (tail) {
|
| 220 |
-
segments.push({
|
| 221 |
-
type: 'speech',
|
| 222 |
-
text: tail,
|
| 223 |
-
voiceId: currentVoice,
|
| 224 |
-
speed: currentSpeed,
|
| 225 |
-
emotion: currentEmotion
|
| 226 |
-
});
|
| 227 |
-
}
|
| 228 |
-
|
| 229 |
-
if (segments.length === 0 && script.trim()) {
|
| 230 |
-
segments.push({
|
| 231 |
-
type: 'speech',
|
| 232 |
-
text: script.trim(),
|
| 233 |
-
voiceId: defaultVoiceId,
|
| 234 |
-
speed: currentSpeed,
|
| 235 |
-
emotion: null
|
| 236 |
-
});
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
return segments;
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
function isAudioResponse(res) {
|
| 243 |
-
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
| 244 |
-
return ct.includes('audio') || ct.includes('mpeg') || ct.includes('mp3');
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
async function callNoizTTS({ text, voiceId, speed = 1.0, emotion = null }) {
|
| 248 |
-
const resolvedVoiceId = normalizeVoiceId(voiceId);
|
| 249 |
-
|
| 250 |
-
const form = new URLSearchParams();
|
| 251 |
-
form.append('text', text);
|
| 252 |
-
form.append('voice_id', resolvedVoiceId);
|
| 253 |
-
form.append('output_format', 'mp3');
|
| 254 |
-
form.append('speed', String(speed));
|
| 255 |
-
|
| 256 |
-
if (emotion) {
|
| 257 |
-
const emo = typeof emotion === 'string' ? emotion : JSON.stringify(emotion);
|
| 258 |
-
form.append('emo', emo);
|
| 259 |
-
form.append('emotion', emo);
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
const keysToTry = [...NOIZ_API_KEYS, null]; // guest fallback last
|
| 263 |
-
|
| 264 |
-
for (const key of keysToTry) {
|
| 265 |
-
const headers = {
|
| 266 |
-
'Content-Type': 'application/x-www-form-urlencoded',
|
| 267 |
-
'Accept': 'audio/mpeg'
|
| 268 |
-
};
|
| 269 |
-
|
| 270 |
-
if (key) {
|
| 271 |
-
headers['Authorization'] = key;
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
try {
|
| 275 |
-
const res = await fetch(`${NOIZ_BASE}/text-to-speech`, {
|
| 276 |
-
method: 'POST',
|
| 277 |
-
headers,
|
| 278 |
-
body: form.toString(),
|
| 279 |
-
signal: AbortSignal.timeout(60000)
|
| 280 |
-
});
|
| 281 |
-
|
| 282 |
-
console.log({
|
| 283 |
-
status: res.status,
|
| 284 |
-
contentType: res.headers.get('content-type'),
|
| 285 |
-
contentLength: res.headers.get('content-length'),
|
| 286 |
-
mode: key ? 'api' : 'guest'
|
| 287 |
-
});
|
| 288 |
-
|
| 289 |
-
if (!res.ok) {
|
| 290 |
-
const errText = await res.text().catch(() => '');
|
| 291 |
-
console.error(`β Noiz error ${res.status}: ${errText}`);
|
| 292 |
-
if (res.status === 401 && key) continue;
|
| 293 |
-
if (!key) continue;
|
| 294 |
-
throw new Error(errText || `Noiz error ${res.status}`);
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
if (!isAudioResponse(res)) {
|
| 298 |
-
const body = await res.text().catch(() => '');
|
| 299 |
-
throw new Error(`Expected audio, got: ${body.slice(0, 200)}`);
|
| 300 |
-
}
|
| 301 |
-
|
| 302 |
-
const audioBuffer = Buffer.from(await res.arrayBuffer());
|
| 303 |
-
console.log(`π΅ Audio bytes received: ${audioBuffer.length}`);
|
| 304 |
-
|
| 305 |
-
if (audioBuffer.length < 500) {
|
| 306 |
-
console.warn('β οΈ Tiny response, skipping');
|
| 307 |
-
continue;
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
return audioBuffer;
|
| 311 |
-
} catch (err) {
|
| 312 |
-
if (err?.name === 'AbortError') {
|
| 313 |
-
console.warn('β± Request timed out');
|
| 314 |
-
continue;
|
| 315 |
-
}
|
| 316 |
-
console.error('π Fetch error:', err.message);
|
| 317 |
-
continue;
|
| 318 |
-
}
|
| 319 |
-
}
|
| 320 |
-
|
| 321 |
-
throw new Error('All Noiz attempts failed');
|
| 322 |
-
}
|
| 323 |
-
|
| 324 |
app.get('/health', (req, res) => {
|
| 325 |
-
|
| 326 |
-
ok: true,
|
| 327 |
-
keysLoaded: NOIZ_API_KEYS.length,
|
| 328 |
-
service: 'Noiz Voice Studio'
|
| 329 |
-
});
|
| 330 |
});
|
| 331 |
|
| 332 |
app.post('/api/generate-voice', async (req, res) => {
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
return res.status(400).json({ error: 'Missing script' });
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
if (script.length > 5000) {
|
| 340 |
-
return res.status(400).json({ error: 'Script too long (max 5000 characters)' });
|
| 341 |
-
}
|
| 342 |
-
|
| 343 |
-
try {
|
| 344 |
-
const segments = parseCinematicScript(script, voiceId, speed);
|
| 345 |
-
console.log(`π¬ Parsed ${segments.length} segment(s)`);
|
| 346 |
-
|
| 347 |
-
const outputSegments = [];
|
| 348 |
-
|
| 349 |
-
for (const seg of segments) {
|
| 350 |
-
if (seg.type === 'pause') {
|
| 351 |
-
outputSegments.push({
|
| 352 |
-
type: 'pause',
|
| 353 |
-
duration: seg.duration
|
| 354 |
-
});
|
| 355 |
-
continue;
|
| 356 |
-
}
|
| 357 |
-
|
| 358 |
-
console.log(
|
| 359 |
-
`π€ ${seg.voiceId} | speed=${seg.speed} | emotion=${seg.emotion ? 'yes' : 'no'} | text="${seg.text.slice(0, 50)}"`
|
| 360 |
-
);
|
| 361 |
-
|
| 362 |
-
const audioBuffer = await callNoizTTS({
|
| 363 |
-
text: seg.text,
|
| 364 |
-
voiceId: seg.voiceId,
|
| 365 |
-
speed: seg.speed,
|
| 366 |
-
emotion: seg.emotion
|
| 367 |
-
});
|
| 368 |
-
|
| 369 |
-
outputSegments.push({
|
| 370 |
-
type: 'speech',
|
| 371 |
-
voiceId: seg.voiceId,
|
| 372 |
-
speed: seg.speed,
|
| 373 |
-
emotion: seg.emotion,
|
| 374 |
-
text: seg.text,
|
| 375 |
-
audio: audioBuffer.toString('base64'),
|
| 376 |
-
format: 'mp3'
|
| 377 |
-
});
|
| 378 |
-
}
|
| 379 |
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
|
|
|
| 389 |
}
|
| 390 |
-
|
| 391 |
-
return res.json({
|
| 392 |
-
mode: 'timeline',
|
| 393 |
-
format: 'mp3',
|
| 394 |
-
segments: outputSegments
|
| 395 |
-
});
|
| 396 |
-
} catch (err) {
|
| 397 |
-
console.error('β Generation failed:', err.message);
|
| 398 |
-
return res.status(500).json({ error: err.message });
|
| 399 |
-
}
|
| 400 |
});
|
| 401 |
|
| 402 |
-
app.listen(7860, () => {
|
| 403 |
-
console.log('π Cinematic Voice Studio ready');
|
| 404 |
-
});
|
|
|
|
| 2 |
import cors from 'cors';
|
| 3 |
import fetch from 'node-fetch';
|
| 4 |
import rateLimit from 'express-rate-limit';
|
| 5 |
+
|
| 6 |
const app = express();
|
| 7 |
+
|
| 8 |
+
// ββ CORS & Middleware (MUST come before routes) ββ
|
| 9 |
+
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'https://domify-academy.free.nf')
|
| 10 |
+
.split(',')
|
| 11 |
+
.map(s => s.trim())
|
| 12 |
+
.filter(Boolean);
|
| 13 |
+
|
| 14 |
+
app.use(cors({
|
| 15 |
+
origin: (origin, callback) => {
|
| 16 |
+
if (!origin) return callback(null, true);
|
| 17 |
+
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
|
| 18 |
+
return callback(new Error('CORS blocked'));
|
| 19 |
+
}
|
| 20 |
+
}));
|
| 21 |
+
app.use(express.json({ limit: '50kb' }));
|
| 22 |
+
app.use('/api/', rateLimit({ windowMs: 15 * 60 * 1000, max: 30 }));
|
| 23 |
+
|
| 24 |
+
// ββ TTS Configuration ββ
|
| 25 |
+
const rawKeys = process.env.NOIZ_API_KEYS || '';
|
| 26 |
+
const NOIZ_API_KEYS = rawKeys ? rawKeys.split(',').map(k => k.trim()).filter(Boolean) : [];
|
| 27 |
+
console.log(`π Loaded ${NOIZ_API_KEYS.length} key(s)`);
|
| 28 |
+
|
| 29 |
+
const NOIZ_BASE = 'https://noiz.ai/v1';
|
| 30 |
+
const VOICE_MAP = {
|
| 31 |
+
'educational-male': '95814add', 'healer-serena': '5a68d66b',
|
| 32 |
+
'naturalist-soren': 'a845c7de', 'mentor-kai': '883b6b7c',
|
| 33 |
+
'mentor-maya': '0e4ab6ec', 'explainer-male': '95814add',
|
| 34 |
+
'narrator-female': '5a68d66b', 'robot-ai': '883b6b7b',
|
| 35 |
+
'dark-narrator': '883b6b7b', 'epic-narrator': '95814add'
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const EMOTION_MAP = {
|
| 39 |
+
calm: { Neutral: 0.8 }, neutral: { Neutral: 0.8 }, joyful: { Joy: 0.8 },
|
| 40 |
+
happy: { Joy: 0.8 }, sad: { Sadness: 0.8 }, angry: { Anger: 0.8 },
|
| 41 |
+
surprised: { Surprise: 0.8 }, dramatic: { Anger: 0.4, Sadness: 0.4 },
|
| 42 |
+
excited: { Joy: 0.6, Surprise: 0.4 }, urgent: { Anger: 0.5, Surprise: 0.5 },
|
| 43 |
+
mysterious: { Neutral: 0.3, Surprise: 0.7 }, confident: { Joy: 0.3, Neutral: 0.7 },
|
| 44 |
+
fearful: { Sadness: 0.4, Surprise: 0.4 },
|
| 45 |
+
'π': { Joy: 0.8 }, 'π’': { Sadness: 0.8 }, 'π‘': { Anger: 0.8 },
|
| 46 |
+
'π²': { Surprise: 0.8 }, 'π€': { Neutral: 0.5 }, 'π₯Ή': { Sadness: 0.5, Joy: 0.3 },
|
| 47 |
+
'π': { Sadness: 0.6 }, 'πͺ': { Confidence: 0.8 }
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
// ββ TTS Helper Functions ββ
|
| 51 |
+
function clampNumber(value, min, max, fallback) {
|
| 52 |
+
const n = Number(value);
|
| 53 |
+
if (Number.isNaN(n)) return fallback;
|
| 54 |
+
return Math.max(min, Math.min(max, n));
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function normalizeVoiceId(tagValue) {
|
| 58 |
+
const v = String(tagValue || '').trim();
|
| 59 |
+
return VOICE_MAP[v] || v;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function parseCinematicScript(script, defaultVoiceId = 'mentor-kai', defaultSpeed = 1.0) {
|
| 63 |
+
const segments = [];
|
| 64 |
+
const regex = /\[([^\]]+)\]/g;
|
| 65 |
+
let lastIndex = 0;
|
| 66 |
+
let currentVoice = defaultVoiceId;
|
| 67 |
+
let currentSpeed = clampNumber(defaultSpeed, 0.5, 2.0, 1.0);
|
| 68 |
+
let currentEmotion = null;
|
| 69 |
+
let match;
|
| 70 |
+
|
| 71 |
+
while ((match = regex.exec(script)) !== null) {
|
| 72 |
+
const tagContent = match[1].trim();
|
| 73 |
+
const tagStart = match.index;
|
| 74 |
+
const tagEnd = match.index + match[0].length;
|
| 75 |
+
const textBeforeTag = script.slice(lastIndex, tagStart).trim();
|
| 76 |
+
|
| 77 |
+
if (textBeforeTag) {
|
| 78 |
+
segments.push({ type: 'speech', text: textBeforeTag, voiceId: currentVoice, speed: currentSpeed, emotion: currentEmotion });
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const lower = tagContent.toLowerCase();
|
| 82 |
+
if (lower.startsWith('pause:')) {
|
| 83 |
+
const duration = clampNumber(lower.split(':')[1], 0, 5000, 500);
|
| 84 |
+
segments.push({ type: 'pause', duration });
|
| 85 |
+
} else if (lower.startsWith('voice:')) {
|
| 86 |
+
const voiceName = tagContent.slice(tagContent.indexOf(':') + 1).trim();
|
| 87 |
+
if (voiceName) currentVoice = normalizeVoiceId(voiceName);
|
| 88 |
+
} else if (lower.startsWith('speed:')) {
|
| 89 |
+
const speedValue = tagContent.slice(tagContent.indexOf(':') + 1).trim();
|
| 90 |
+
currentSpeed = clampNumber(speedValue, 0.5, 2.0, currentSpeed);
|
| 91 |
+
} else if (EMOTION_MAP[tagContent] || EMOTION_MAP[lower]) {
|
| 92 |
+
currentEmotion = EMOTION_MAP[tagContent] || EMOTION_MAP[lower];
|
| 93 |
+
}
|
| 94 |
+
lastIndex = tagEnd;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const tail = script.slice(lastIndex).trim();
|
| 98 |
+
if (tail) segments.push({ type: 'speech', text: tail, voiceId: currentVoice, speed: currentSpeed, emotion: currentEmotion });
|
| 99 |
+
if (segments.length === 0 && script.trim()) {
|
| 100 |
+
segments.push({ type: 'speech', text: script.trim(), voiceId: defaultVoiceId, speed: currentSpeed, emotion: null });
|
| 101 |
+
}
|
| 102 |
+
return segments;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function isAudioResponse(res) {
|
| 106 |
+
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
| 107 |
+
return ct.includes('audio') || ct.includes('mpeg') || ct.includes('mp3');
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async function callNoizTTS({ text, voiceId, speed = 1.0, emotion = null }) {
|
| 111 |
+
const resolvedVoiceId = normalizeVoiceId(voiceId);
|
| 112 |
+
const form = new URLSearchParams();
|
| 113 |
+
form.append('text', text);
|
| 114 |
+
form.append('voice_id', resolvedVoiceId);
|
| 115 |
+
form.append('output_format', 'mp3');
|
| 116 |
+
form.append('speed', String(speed));
|
| 117 |
+
if (emotion) {
|
| 118 |
+
const emo = typeof emotion === 'string' ? emotion : JSON.stringify(emotion);
|
| 119 |
+
form.append('emo', emo);
|
| 120 |
+
form.append('emotion', emo);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const keysToTry = [...NOIZ_API_KEYS, null];
|
| 124 |
+
for (const key of keysToTry) {
|
| 125 |
+
const headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'audio/mpeg' };
|
| 126 |
+
if (key) headers['Authorization'] = key;
|
| 127 |
+
|
| 128 |
+
try {
|
| 129 |
+
const res = await fetch(`${NOIZ_BASE}/text-to-speech`, { method: 'POST', headers, body: form.toString(), signal: AbortSignal.timeout(60000) });
|
| 130 |
+
console.log({ status: res.status, contentType: res.headers.get('content-type'), contentLength: res.headers.get('content-length'), mode: key ? 'api' : 'guest' });
|
| 131 |
+
|
| 132 |
+
if (!res.ok) {
|
| 133 |
+
const errText = await res.text().catch(() => '');
|
| 134 |
+
console.error(`β Noiz error ${res.status}: ${errText}`);
|
| 135 |
+
if (res.status === 401 && key) continue;
|
| 136 |
+
if (!key) continue;
|
| 137 |
+
throw new Error(errText || `Noiz error ${res.status}`);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (!isAudioResponse(res)) {
|
| 141 |
+
const body = await res.text().catch(() => '');
|
| 142 |
+
throw new Error(`Expected audio, got: ${body.slice(0, 200)}`);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
const audioBuffer = Buffer.from(await res.arrayBuffer());
|
| 146 |
+
console.log(`π΅ Audio bytes received: ${audioBuffer.length}`);
|
| 147 |
+
if (audioBuffer.length < 500) { console.warn('β οΈ Tiny response, skipping'); continue; }
|
| 148 |
+
return audioBuffer;
|
| 149 |
+
} catch (err) {
|
| 150 |
+
if (err?.name === 'AbortError') { console.warn('β± Request timed out'); continue; }
|
| 151 |
+
console.error('π Fetch error:', err.message);
|
| 152 |
+
continue;
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
throw new Error('All Noiz attempts failed');
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// βββββββββββββββββββββββββββββββββββββββββββ
|
| 159 |
+
// ISABELLA CONSULTANT (imports + routes)
|
| 160 |
+
// βββββββββββββββββββββββββββββββββββββββββββ
|
| 161 |
import { searchDuckDuckGo, scrapeSiteKnowledge, buildIsabellaPrompt, callNVIDIA } from './consultant.js';
|
| 162 |
|
| 163 |
const ISABELLA_NVIDIA_KEY = process.env.ISABELLA_NVIDIA_KEY || '';
|
| 164 |
|
|
|
|
| 165 |
const KNOWLEDGE_URLS = [
|
| 166 |
'https://domify-academy.free.nf/about-us',
|
| 167 |
+
'https://domify-academy.free.nf/product-Price',
|
| 168 |
'https://domify-academy.free.nf/refund',
|
| 169 |
+
'https://domify-academy.free.nf/privacy',
|
| 170 |
+
'https://domify-academy.free.nf'
|
| 171 |
];
|
| 172 |
|
| 173 |
let cachedKnowledge = null;
|
|
|
|
| 175 |
|
| 176 |
async function getKnowledge() {
|
| 177 |
const now = Date.now();
|
| 178 |
+
if (cachedKnowledge && (now - knowledgeLastFetched) < 30 * 60 * 1000) return cachedKnowledge;
|
|
|
|
|
|
|
| 179 |
cachedKnowledge = await scrapeSiteKnowledge(KNOWLEDGE_URLS);
|
| 180 |
knowledgeLastFetched = now;
|
| 181 |
console.log('π Isabella knowledge base refreshed');
|
|
|
|
| 185 |
app.post('/api/isabella/chat', async (req, res) => {
|
| 186 |
try {
|
| 187 |
const { message, userName, pageName, conversationHistory = [] } = req.body;
|
| 188 |
+
if (!message) return res.status(400).json({ error: 'Message required' });
|
| 189 |
+
if (!ISABELLA_NVIDIA_KEY) return res.status(500).json({ error: 'Isabella NVIDIA key not configured' });
|
| 190 |
|
| 191 |
+
const [knowledge, searchResults] = await Promise.all([getKnowledge(), searchDuckDuckGo(message)]);
|
| 192 |
+
const systemPrompt = buildIsabellaPrompt(userName || '', pageName || 'unknown', knowledge, searchResults);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
const reply = await callNVIDIA(systemPrompt, message, ISABELLA_NVIDIA_KEY, conversationHistory);
|
| 194 |
|
|
|
|
| 195 |
let issue = null;
|
| 196 |
const issueMatch = reply.match(/\[ISSUE:\s*(.+?)\]/);
|
| 197 |
+
if (issueMatch) { issue = issueMatch[1]; reply = reply.replace(/\[ISSUE:.*?\]/g, '').trim(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
+
res.json({ reply, issue, knowledgeRefreshed: Date.now() - knowledgeLastFetched < 60000 });
|
| 200 |
} catch (err) {
|
| 201 |
console.error('Isabella error:', err.message);
|
| 202 |
res.status(500).json({ error: 'Isabella is thinking... try again' });
|
| 203 |
}
|
| 204 |
});
|
| 205 |
|
|
|
|
| 206 |
app.post('/api/isabella/refresh-knowledge', async (req, res) => {
|
| 207 |
cachedKnowledge = await scrapeSiteKnowledge(KNOWLEDGE_URLS);
|
| 208 |
knowledgeLastFetched = Date.now();
|
| 209 |
res.json({ success: true, pages: cachedKnowledge.length });
|
| 210 |
});
|
| 211 |
|
| 212 |
+
// βββββββββββββββββββββββββββββββββββββββββββ
|
| 213 |
+
// TTS ENDPOINTS
|
| 214 |
+
// βββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
app.get('/health', (req, res) => {
|
| 216 |
+
res.json({ ok: true, keysLoaded: NOIZ_API_KEYS.length, service: 'Noiz Voice Studio' });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
});
|
| 218 |
|
| 219 |
app.post('/api/generate-voice', async (req, res) => {
|
| 220 |
+
const { script, voiceId = 'mentor-kai', speed = 1.0 } = req.body || {};
|
| 221 |
+
if (!script || typeof script !== 'string') return res.status(400).json({ error: 'Missing script' });
|
| 222 |
+
if (script.length > 5000) return res.status(400).json({ error: 'Script too long (max 5000 characters)' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
+
try {
|
| 225 |
+
const segments = parseCinematicScript(script, voiceId, speed);
|
| 226 |
+
console.log(`π Parsed ${segments.length} segment(s)`);
|
| 227 |
+
const outputSegments = [];
|
| 228 |
+
|
| 229 |
+
for (const seg of segments) {
|
| 230 |
+
if (seg.type === 'pause') { outputSegments.push({ type: 'pause', duration: seg.duration }); continue; }
|
| 231 |
+
console.log(`π€ ${seg.voiceId} | speed=${seg.speed} | emotion=${seg.emotion ? 'yes' : 'no'} | text="${seg.text.slice(0, 50)}"`);
|
| 232 |
+
const audioBuffer = await callNoizTTS({ text: seg.text, voiceId: seg.voiceId, speed: seg.speed, emotion: seg.emotion });
|
| 233 |
+
outputSegments.push({ type: 'speech', voiceId: seg.voiceId, speed: seg.speed, emotion: seg.emotion, text: seg.text, audio: audioBuffer.toString('base64'), format: 'mp3' });
|
| 234 |
+
}
|
| 235 |
|
| 236 |
+
const speechCount = outputSegments.filter(s => s.type === 'speech').length;
|
| 237 |
+
if (speechCount === 1 && outputSegments.length === 1) {
|
| 238 |
+
return res.json({ mode: 'single', format: 'mp3', audio: outputSegments[0].audio, segments: outputSegments });
|
| 239 |
+
}
|
| 240 |
+
return res.json({ mode: 'timeline', format: 'mp3', segments: outputSegments });
|
| 241 |
+
} catch (err) {
|
| 242 |
+
console.error('β Generation failed:', err.message);
|
| 243 |
+
return res.status(500).json({ error: err.message });
|
| 244 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
});
|
| 246 |
|
| 247 |
+
app.listen(7860, () => { console.log('π Cinematic Voice Studio ready'); });
|
|
|
|
|
|