require("dotenv").config(); const express = require("express"); const fs = require("fs"); const path = require("path"); const https = require("https"); const os = require("os"); const crypto = require("crypto"); const { spawn } = require("child_process"); const { spawnSync } = require("child_process"); const compression = require("compression"); const helmet = require("helmet"); function parsePort(value, fallback) { const parsed = Number(value); if (Number.isInteger(parsed) && parsed >= 0) { return parsed; } return fallback; } const app = express(); const PORT = parsePort(process.env.PORT, 3113); const HOST = process.env.HOST || "0.0.0.0"; const DATA_DIR = path.join(__dirname, "data"); const PUBLIC_DIR = path.join(__dirname, "public"); const OPENAI_API_KEY = String(process.env.OPENAI_API_KEY || process.env.GPTAPI_KEY || "").trim(); const OPENAI_MODEL = String(process.env.OPENAI_MODEL || "gpt-4.1-mini").trim(); const TTS_PROVIDER = String(process.env.TTS_PROVIDER || "browser").toLowerCase(); const PIPER_PATH = String(process.env.PIPER_PATH || "piper").trim(); const PIPER_MODEL = String(process.env.PIPER_MODEL || "").trim(); const PIPER_VOICE = String(process.env.PIPER_VOICE || "").trim(); const PIPER_VOICES_DIR = path.resolve(String(process.env.PIPER_VOICES_DIR || path.join(__dirname, "piper-voices")).trim()); const AUDIO_CACHE_DIR = path.join(__dirname, "audio-cache"); const PIPER_BIN_DIR = path.join(__dirname, ".piper-bin"); // Ensure data directory exists if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } // Ensure audio cache directory exists if (!fs.existsSync(AUDIO_CACHE_DIR)) { fs.mkdirSync(AUDIO_CACHE_DIR, { recursive: true }); } function getBundledPiperArchiveForCurrentPlatform() { if (process.platform !== "linux") return null; if (process.arch === "x64") { return path.join(__dirname, "piper_amd64.tar.gz"); } if (process.arch === "arm64") { return path.join(__dirname, "piper_arm64.tar.gz"); } if (process.arch === "arm") { return path.join(__dirname, "piper_armv7.tar.gz"); } return null; } function ensureBundledPiperBinary() { if (PIPER_PATH !== "piper") { return PIPER_PATH; } const archivePath = getBundledPiperArchiveForCurrentPlatform(); if (!archivePath || !fs.existsSync(archivePath)) { return PIPER_PATH; } const archDir = process.arch === "x64" ? "amd64" : process.arch === "arm64" ? "arm64" : "armv7"; const targetDir = path.join(PIPER_BIN_DIR, archDir); const binaryPath = path.join(targetDir, "piper", "piper"); if (!fs.existsSync(binaryPath)) { fs.mkdirSync(targetDir, { recursive: true }); const result = spawnSync("tar", ["-xzf", archivePath, "-C", targetDir], { stdio: "pipe", encoding: "utf8", }); if (result.status !== 0) { throw new Error(result.stderr || `Failed to extract bundled Piper archive: ${archivePath}`); } } try { fs.chmodSync(binaryPath, 0o755); } catch { // Non-fatal on systems that don't support chmod semantics here. } return binaryPath; } const RESOLVED_PIPER_PATH = ensureBundledPiperBinary(); // Generate a cache key from text function normalizeVoiceId(value) { return String(value || "") .trim() .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, ""); } function defaultVoiceIdFromModelPath(modelPath) { if (!modelPath) return "default"; const basename = path.basename(modelPath, path.extname(modelPath)); return normalizeVoiceId(basename) || "default"; } function prettyVoiceLabel(id) { const clean = String(id || "").replace(/[_-]+/g, " ").trim(); return clean ? clean.replace(/\b\w/g, (ch) => ch.toUpperCase()) : "Default"; } function listAvailablePiperVoices() { const voices = new Map(); if (PIPER_MODEL) { const id = normalizeVoiceId(PIPER_VOICE || defaultVoiceIdFromModelPath(PIPER_MODEL)); if (id) { voices.set(id, { id, label: `${prettyVoiceLabel(id)} (default)`, modelPath: path.resolve(PIPER_MODEL), isDefault: true, }); } } if (fs.existsSync(PIPER_VOICES_DIR)) { const files = fs.readdirSync(PIPER_VOICES_DIR).filter((name) => name.toLowerCase().endsWith(".onnx")); for (const file of files) { const id = normalizeVoiceId(path.basename(file, path.extname(file))); if (!id || voices.has(id)) continue; voices.set(id, { id, label: prettyVoiceLabel(id), modelPath: path.join(PIPER_VOICES_DIR, file), isDefault: false, }); } } return [...voices.values()]; } function resolvePiperVoice(voiceId) { const available = listAvailablePiperVoices(); const requestedId = normalizeVoiceId(voiceId); if (requestedId) { const match = available.find((voice) => voice.id === requestedId); if (match) return match; } const envDefaultId = normalizeVoiceId(PIPER_VOICE || defaultVoiceIdFromModelPath(PIPER_MODEL)); if (envDefaultId) { const envDefault = available.find((voice) => voice.id === envDefaultId); if (envDefault) return envDefault; } if (available.length > 0) { return available[0]; } if (PIPER_MODEL) { const fallbackId = normalizeVoiceId(defaultVoiceIdFromModelPath(PIPER_MODEL)); return { id: fallbackId || "default", label: prettyVoiceLabel(fallbackId || "default"), modelPath: path.resolve(PIPER_MODEL), isDefault: true, }; } throw new Error("No Piper voice model found. Configure PIPER_MODEL or add .onnx files to piper-voices/"); } function getAudioCacheKey(text, voiceId = "default") { const normalizedVoice = normalizeVoiceId(voiceId) || "default"; const hash = crypto.createHash("md5").update(`${normalizedVoice}::${text.toLowerCase().trim()}`).digest("hex"); return `${hash}.wav`; } // Check if audio is cached function isAudioCached(text, voiceId = "default") { const cacheFile = path.join(AUDIO_CACHE_DIR, getAudioCacheKey(text, voiceId)); return fs.existsSync(cacheFile); } // Get cached audio buffer or null function getCachedAudio(text, voiceId = "default") { const cacheFile = path.join(AUDIO_CACHE_DIR, getAudioCacheKey(text, voiceId)); if (fs.existsSync(cacheFile)) { try { return fs.readFileSync(cacheFile); } catch { return null; } } return null; } // Save audio to cache function cacheAudio(text, buffer, voiceId = "default") { const cacheFile = path.join(AUDIO_CACHE_DIR, getAudioCacheKey(text, voiceId)); try { fs.writeFileSync(cacheFile, buffer); } catch (err) { console.warn("Failed to cache audio:", err.message); } } // Helper to make HTTPS GET requests function httpsGet(url) { return new Promise((resolve, reject) => { const parsed = new URL(url); const options = { hostname: parsed.hostname, port: 443, path: parsed.pathname + parsed.search, method: "GET", headers: { "User-Agent": "GuessGameApp/1.0 (Educational kids game)", }, rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== "0", }; const req = https.request(options, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { resolve({ ok: true, data: JSON.parse(data) }); } catch { resolve({ ok: true, data: data }); } } else { resolve({ ok: false, status: res.statusCode, error: data }); } }); }); req.on("error", reject); req.end(); }); } // Helper to make HTTPS POST requests (more reliable than fetch on some Windows setups) function httpsPost(url, headers, body) { return new Promise((resolve, reject) => { const parsed = new URL(url); const options = { hostname: parsed.hostname, port: 443, path: parsed.pathname + parsed.search, method: "POST", headers: { ...headers, "Content-Type": "application/json", }, // Allow bypassing SSL cert issues in development (set NODE_TLS_REJECT_UNAUTHORIZED=0) rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== "0", }; const req = https.request(options, (res) => { let data = ""; res.on("data", (chunk) => (data += chunk)); res.on("end", () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { resolve({ ok: true, data: JSON.parse(data) }); } catch { resolve({ ok: true, data: data }); } } else { resolve({ ok: false, status: res.statusCode, error: data }); } }); }); req.on("error", reject); req.write(typeof body === "string" ? body : JSON.stringify(body)); req.end(); }); } app.use(express.json({ limit: "1mb" })); app.disable("x-powered-by"); app.use( helmet({ contentSecurityPolicy: false, }), ); app.use(compression()); app.use( express.static(PUBLIC_DIR, { extensions: ["html"], etag: true, maxAge: "1h", }), ); // ===== INDIVIDUAL DATASET FILE STORAGE ===== // Each dataset is stored as its own JSON file: data/{id}.json function getDatasetPath(id) { return path.join(DATA_DIR, `${id}.json`); } function findDatasetFilePathById(id) { if (!id || !fs.existsSync(DATA_DIR)) return null; const directPath = getDatasetPath(id); if (fs.existsSync(directPath)) { return directPath; } const files = fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json") && f !== "datasets.json"); for (const file of files) { const filePath = path.join(DATA_DIR, file); try { const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw); if (parsed && parsed.id === id) { return filePath; } } catch { // Ignore bad files while searching. } } return null; } function listAllDatasets() { try { if (!fs.existsSync(DATA_DIR)) return []; const files = fs.readdirSync(DATA_DIR).filter((f) => f.endsWith(".json") && f !== "datasets.json"); const byId = new Map(); for (const file of files) { try { const raw = fs.readFileSync(path.join(DATA_DIR, file), "utf8"); const ds = JSON.parse(raw); if (ds && ds.id && ds.title) { const existing = byId.get(ds.id); if (!existing) { byId.set(ds.id, ds); continue; } const existingScore = (String(existing.coverImage || "").trim() ? 10 : 0) + (Array.isArray(existing.items) ? existing.items.length : 0); const incomingScore = (String(ds.coverImage || "").trim() ? 10 : 0) + (Array.isArray(ds.items) ? ds.items.length : 0); // Prefer richer dataset when duplicate ids exist. if (incomingScore >= existingScore) { byId.set(ds.id, ds); } } } catch { // Skip corrupted files console.warn(`Skipping corrupted dataset file: ${file}`); } } return [...byId.values()]; } catch (err) { console.error("Failed to list datasets", err); return []; } } function readDataset(id) { const filePath = findDatasetFilePathById(id); if (!filePath || !fs.existsSync(filePath)) return null; try { const raw = fs.readFileSync(filePath, "utf8"); return JSON.parse(raw); } catch (err) { console.error(`Failed to read dataset ${id}`, err); return null; } } function writeDataset(dataset) { const filePath = getDatasetPath(dataset.id); fs.writeFileSync(filePath, JSON.stringify(dataset, null, 2), "utf8"); } function deleteDataset(id) { const filePath = findDatasetFilePathById(id); if (filePath && fs.existsSync(filePath)) { fs.unlinkSync(filePath); return true; } return false; } // Migrate from old datasets.json format if it exists function migrateOldFormat() { const oldPath = path.join(DATA_DIR, "datasets.json"); if (!fs.existsSync(oldPath)) return; try { const raw = fs.readFileSync(oldPath, "utf8"); const store = JSON.parse(raw); if (store && Array.isArray(store.datasets)) { for (const ds of store.datasets) { if (ds.id && ds.title) { const newPath = getDatasetPath(ds.id); if (!fs.existsSync(newPath)) { writeDataset(ds); console.log(`Migrated dataset: ${ds.id}`); } } } // Rename old file as backup fs.renameSync(oldPath, path.join(DATA_DIR, "datasets.json.backup")); console.log("Migration complete. Old file renamed to datasets.json.backup"); } } catch (err) { console.error("Migration failed", err); } } migrateOldFormat(); function normalizeDifficulty(value) { const v = String(value || "") .toLowerCase() .trim(); if (v === "easy" || v === "medium" || v === "hard") return v; return "medium"; } function sanitizeItem(item) { const hints = Array.isArray(item?.hints) ? item.hints .map((h) => String(h || "").trim()) .filter(Boolean) .slice(0, 3) : []; while (hints.length < 3) hints.push("No hint yet."); const facts = Array.isArray(item?.facts) ? item.facts .map((f) => String(f || "").trim()) .filter(Boolean) .slice(0, 5) : []; return { title: String(item?.title || "Guess this thing").trim() || "Guess this thing", difficulty: normalizeDifficulty(item?.difficulty), hints, answer: String(item?.answer || "Unknown").trim() || "Unknown", image: String(item?.image || "").trim(), facts, }; } function slugify(text) { return ( String(text || "") .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, "") || `dataset-${Date.now()}` ); } function tryParseJsonObject(text) { if (!text || typeof text !== "string") return null; const firstBrace = text.indexOf("{"); const lastBrace = text.lastIndexOf("}"); if (firstBrace < 0 || lastBrace < 0 || lastBrace <= firstBrace) return null; try { return JSON.parse(text.slice(firstBrace, lastBrace + 1)); } catch { return null; } } async function synthesizeWithPiper(text, { useCache = true, voiceId = "" } = {}) { const voice = resolvePiperVoice(voiceId); // Check cache first if (useCache) { const cached = getCachedAudio(text, voice.id); if (cached) { return cached; } } const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `guessgame-tts-${voice.id}-`)); const outputPath = path.join(tempDir, `speech-${Date.now()}.wav`); await new Promise((resolve, reject) => { const child = spawn(RESOLVED_PIPER_PATH, ["--model", voice.modelPath, "--output_file", outputPath], { stdio: ["pipe", "pipe", "pipe"] }); let stderr = ""; child.stderr.on("data", (chunk) => { stderr += String(chunk); }); child.on("error", (err) => { reject(err); }); child.on("close", (code) => { if (code === 0) { resolve(); return; } reject(new Error(stderr || `Piper exited with code ${code}`)); }); child.stdin.write(text); child.stdin.end(); }); try { const buffer = fs.readFileSync(outputPath); // Cache the generated audio if (useCache) { cacheAudio(text, buffer, voice.id); } return buffer; } finally { try { if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); fs.rmdirSync(tempDir); } catch { // Non-fatal cleanup failure } } } app.get("/api/datasets", (_req, res) => { const datasets = listAllDatasets().map((ds) => ({ id: ds.id, title: ds.title, description: ds.description || "", coverImage: String(ds.coverImage || "").trim(), count: Array.isArray(ds.items) ? ds.items.length : 0, })); res.json({ datasets }); }); app.get("/api/health", (_req, res) => { res.json({ ok: true }); }); app.get("/api/datasets/:id", (req, res) => { const dataset = readDataset(req.params.id); if (!dataset) { return res.status(404).json({ error: "Dataset not found" }); } res.json({ dataset }); }); app.post("/api/datasets", (req, res) => { const body = req.body || {}; const title = String(body.title || "").trim(); const id = slugify(body.id || title); const items = Array.isArray(body.items) ? body.items.map(sanitizeItem) : []; if (!title) { return res.status(400).json({ error: "title is required" }); } if (items.length === 0) { return res.status(400).json({ error: "items must include at least one entry" }); } const dataset = { id, title, description: String(body.description || "").trim(), coverImage: String(body.coverImage || "").trim(), items, }; writeDataset(dataset); res.json({ ok: true, dataset }); }); app.delete("/api/datasets/:id", (req, res) => { const id = req.params.id; if (!id) { return res.status(400).json({ error: "Dataset ID required" }); } const deleted = deleteDataset(id); if (deleted) { res.json({ ok: true, message: `Dataset ${id} deleted` }); } else { res.status(404).json({ error: "Dataset not found" }); } }); app.post("/api/generate-dataset", async (req, res) => { const body = req.body || {}; const category = String(body.category || "").trim(); const count = Math.max(4, Math.min(30, Number(body.count) || 8)); const model = String(body.model || OPENAI_MODEL).trim(); const knownItems = String(body.knownItems || "").trim(); // Optional: user-provided list of real items if (!category) { return res.status(400).json({ error: "category is required" }); } if (!OPENAI_API_KEY) { return res.status(500).json({ error: "Missing API key in .env. Set OPENAI_API_KEY (or GPTAPI_KEY)." }); } // Improved prompt that prevents hallucinations let prompt = `You are creating a kid-friendly guessing game dataset. Return ONLY valid JSON, no other text. CRITICAL RULES: 1. ONLY include REAL, VERIFIABLE items that actually exist 2. If asked about a movie/show/game, ONLY use characters/items that ACTUALLY appear in that specific media 3. DO NOT invent or hallucinate characters, names, or facts 4. If you're unsure whether something exists, DO NOT include it 5. Better to return fewer accurate items than many fake ones 6. All hints and facts must be accurate and verifiable Category: ${category} Number of items requested: ${count} `; if (knownItems) { prompt += `\nThe user has provided these known valid items to help you (use these if relevant): ${knownItems}\n`; } prompt += ` Return exactly this JSON structure: { "title": "Descriptive title for ${category}", "description": "Brief description", "items": [ { "title": "Guess this ${category}", "difficulty": "easy|medium|hard", "hints": ["Hint 1 (must be factually accurate)", "Hint 2", "Hint 3"], "answer": "The real, actual answer", "image": "", "facts": ["Real fact 1", "Real fact 2"] } ] } Rules: - Exactly 3 hints per item, all factually accurate - Mix of easy/medium/hard difficulties - 2 educational facts per item (optional but preferred) - image should be empty string - Keep content age-appropriate for children - If this is about specific media (movie, book, game), ONLY include things that actually exist in that media`; try { const result = await httpsPost( "https://api.openai.com/v1/chat/completions", { Authorization: `Bearer ${OPENAI_API_KEY}` }, { model, messages: [{ role: "user", content: prompt }], temperature: 0.3 }, // Lower temperature for more factual responses ); if (!result.ok) { return res.status(500).json({ error: `OpenAI request failed: ${result.error}` }); } const data = result.data; const text = data?.choices?.[0]?.message?.content || ""; const start = text.indexOf("{"); const end = text.lastIndexOf("}"); if (start < 0 || end < 0) { return res.status(500).json({ error: "Model response did not contain JSON." }); } const parsed = JSON.parse(text.slice(start, end + 1)); const dataset = { id: slugify(parsed.title || category), title: String(parsed.title || `${category} Challenge`).trim(), description: String(parsed.description || `Generated dataset for ${category}.`).trim(), items: (Array.isArray(parsed.items) ? parsed.items : []).map(sanitizeItem), }; if (!dataset.items.length) { return res.status(500).json({ error: "Generated dataset had no valid items." }); } writeDataset(dataset); res.json({ ok: true, dataset }); } catch (err) { res.status(500).json({ error: `Generation failed: ${err.message}` }); } }); app.post("/api/suggest-image-urls", async (req, res) => { const body = req.body || {}; const answer = String(body.answer || "").trim(); const source = String(body.source || "wikimedia").toLowerCase(); const context = String(body.context || "").trim(); // Additional context like movie name, category if (!answer) { return res.status(400).json({ error: "answer is required" }); } const searchQuery = context ? `${answer} ${context}` : answer; try { if (source === "wikimedia" || source === "wikipedia") { // Search Wikimedia Commons for images (free API, no key needed, stable URLs) const encodedQuery = encodeURIComponent(searchQuery); const searchUrl = `https://commons.wikimedia.org/w/api.php?action=query&list=search&srsearch=${encodedQuery}&srnamespace=6&srlimit=15&format=json`; const searchResult = await httpsGet(searchUrl); if (!searchResult.ok) { return res.status(500).json({ error: "Wikimedia search failed" }); } const searchData = searchResult.data; const titles = (searchData?.query?.search || []) .map((item) => item.title) .filter((t) => /\.(jpg|jpeg|png|gif|svg|webp)$/i.test(t)); if (!titles.length) { return res.status(404).json({ error: `No images found for "${answer}"`, source: "Wikimedia Commons" }); } const titlesParam = encodeURIComponent(titles.slice(0, 10).join("|")); const infoUrl = `https://commons.wikimedia.org/w/api.php?action=query&titles=${titlesParam}&prop=imageinfo&iiprop=url|size&iiurlwidth=800&format=json`; const infoResult = await httpsGet(infoUrl); if (!infoResult.ok) { return res.status(500).json({ error: "Failed to get image info" }); } const pages = infoResult.data?.query?.pages || {}; const urls = Object.values(pages) .map((page) => { const info = page?.imageinfo?.[0]; return info?.thumburl || info?.url; }) .filter((url) => url && /^https:\/\//i.test(url)); if (!urls.length) { return res.status(404).json({ error: `No valid image URLs found for "${answer}"`, source: "Wikimedia Commons" }); } return res.json({ ok: true, urls, source: "Wikimedia Commons" }); } if (source === "unsplash") { // Unsplash Source API (free, no key needed for basic usage) const encodedQuery = encodeURIComponent(searchQuery); // Generate multiple size variations as options const urls = [ `https://source.unsplash.com/800x600/?${encodedQuery}`, `https://source.unsplash.com/600x400/?${encodedQuery}`, `https://source.unsplash.com/400x300/?${encodedQuery}`, ]; return res.json({ ok: true, urls, source: "Unsplash" }); } if (source === "pexels") { // Pexels doesn't have a free no-key API, return a search link instead const encodedQuery = encodeURIComponent(searchQuery); return res.json({ ok: true, urls: [], searchUrl: `https://www.pexels.com/search/${encodedQuery}/`, source: "Pexels", note: "Pexels requires manual image selection. Visit the search URL to find images.", }); } if (source === "duckduckgo") { // DuckDuckGo image search link (no direct API, but useful for manual lookup) const encodedQuery = encodeURIComponent(searchQuery); return res.json({ ok: true, urls: [], searchUrl: `https://duckduckgo.com/?q=${encodedQuery}&iax=images&ia=images`, source: "DuckDuckGo Images", note: "DuckDuckGo requires manual image selection. Right-click and copy image URL.", }); } // Default: return available sources return res.status(400).json({ error: `Unknown image source: ${source}`, availableSources: ["wikimedia", "unsplash", "duckduckgo", "pexels"], }); } catch (err) { res.status(500).json({ error: `Image search failed: ${err.message}` }); } }); app.get("/api/tts/voices", (_req, res) => { if (TTS_PROVIDER !== "piper") { return res.json({ ok: true, enabled: false, provider: TTS_PROVIDER, voices: [], defaultVoiceId: "", }); } try { const voices = listAvailablePiperVoices().map((voice) => ({ id: voice.id, label: voice.label, isDefault: Boolean(voice.isDefault), })); const defaultVoice = resolvePiperVoice(""); return res.json({ ok: true, enabled: true, provider: TTS_PROVIDER, voices, defaultVoiceId: defaultVoice.id, }); } catch (err) { return res.status(500).json({ error: `Failed to load Piper voices: ${err.message}` }); } }); app.post("/api/tts", async (req, res) => { const text = String(req.body?.text || "").trim(); const voiceId = String(req.body?.voiceId || "").trim(); if (!text) { return res.status(400).json({ error: "text is required" }); } if (TTS_PROVIDER !== "piper") { return res.status(400).json({ error: "Server TTS is disabled. Set TTS_PROVIDER=piper to enable /api/tts." }); } try { const voice = resolvePiperVoice(voiceId); const wavBuffer = await synthesizeWithPiper(text, { useCache: true, voiceId: voice.id }); res.setHeader("Content-Type", "audio/wav"); res.setHeader("Cache-Control", "public, max-age=31536000"); // Cache for 1 year since content is static res.setHeader("X-TTS-Voice", voice.id); return res.send(wavBuffer); } catch (err) { return res.status(500).json({ error: `Piper TTS failed: ${err.message}` }); } }); // Check which audio files are cached for a dataset app.post("/api/tts/check-cache", async (req, res) => { const texts = Array.isArray(req.body?.texts) ? req.body.texts : []; const voiceId = String(req.body?.voiceId || "").trim(); const resolvedVoice = resolvePiperVoice(voiceId); const results = {}; for (const text of texts) { const cleanText = String(text || "").trim(); if (cleanText) { results[cleanText] = isAudioCached(cleanText, resolvedVoice.id); } } res.json({ cached: results, voiceId: resolvedVoice.id }); }); // Pre-generate audio for a dataset (runs in background, returns immediately) app.post("/api/tts/pregenerate", async (req, res) => { if (TTS_PROVIDER !== "piper") { return res.status(400).json({ error: "Server TTS is disabled. Set TTS_PROVIDER=piper to enable pre-generation." }); } const datasetId = String(req.body?.datasetId || "").trim(); const voiceId = String(req.body?.voiceId || "").trim(); if (!datasetId) { return res.status(400).json({ error: "datasetId is required" }); } let resolvedVoice; try { resolvedVoice = resolvePiperVoice(voiceId); } catch (err) { return res.status(400).json({ error: err.message }); } const dataset = readDataset(datasetId); if (!dataset) { return res.status(404).json({ error: "Dataset not found" }); } // Collect all texts that need audio const textsToGenerate = []; for (const item of dataset.items || []) { // Answer announcement const answerText = `The answer is ${item.answer || "Unknown"}`; if (!isAudioCached(answerText, resolvedVoice.id)) { textsToGenerate.push(answerText); } // Each hint for (const hint of item.hints || []) { const hintText = String(hint || "").trim(); if (hintText && !isAudioCached(hintText, resolvedVoice.id)) { textsToGenerate.push(hintText); } } } if (textsToGenerate.length === 0) { return res.json({ ok: true, message: "All audio already cached", generated: 0, total: 0, voiceId: resolvedVoice.id }); } // Start generation in background res.json({ ok: true, message: `Generating ${textsToGenerate.length} audio files in background`, total: textsToGenerate.length, voiceId: resolvedVoice.id }); // Generate audio files sequentially in background (don't await) (async () => { let generated = 0; for (const text of textsToGenerate) { try { await synthesizeWithPiper(text, { useCache: true, voiceId: resolvedVoice.id }); generated++; console.log(`TTS cached (${resolvedVoice.id}): ${generated}/${textsToGenerate.length}`); } catch (err) { console.error(`TTS generation failed for "${text.slice(0, 30)}...":`, err.message); } } console.log(`TTS pre-generation complete (${resolvedVoice.id}): ${generated}/${textsToGenerate.length} files`); })(); }); app.get("*", (req, res, next) => { if (req.path.startsWith("/api/")) { return next(); } res.sendFile(path.join(PUBLIC_DIR, "index.html")); }); app.use((err, _req, res, _next) => { console.error(err); res.status(500).json({ error: "Internal server error" }); }); const server = app.listen(PORT, HOST, () => { console.log(`Guess game running on http://${HOST === "0.0.0.0" ? "localhost" : HOST}:${PORT}`); }); server.on("error", (err) => { if (err.code === "EADDRINUSE") { console.error(`Port ${PORT} is already in use. Stop the other process or set PORT to a different value before starting Guess Game.`); process.exit(1); } console.error("Server failed to start", err); process.exit(1); });