#!/usr/bin/env node /** * Local TTS generation for FalahMobile Learn content. * Uses macOS `say` + `afconvert` (fallback: ffmpeg/lame if available). * Reads prisma/seed-learn.json and generates MP3s for modules with audioPath. */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const PROJECT_ROOT = path.resolve(__dirname, '..'); const SEED_PATH = path.join(PROJECT_ROOT, 'prisma', 'seed-learn.json'); const PUBLIC_DIR = path.join(PROJECT_ROOT, 'public'); // Check available TTS tools function detectTtsEngine() { try { execSync('which say', { stdio: 'ignore' }); return { type: 'say', aiff: true }; } catch {} try { execSync('which espeak', { stdio: 'ignore' }); return { type: 'espeak', wav: true }; } catch {} try { execSync('which piper', { stdio: 'ignore' }); return { type: 'piper', wav: true }; } catch {} return null; } function stripMarkdown(text) { return text .replace(/^---\n[\s\S]*?---\n/, '') .replace(/[\u{1F300}-\u{1F9FF}]/gu, ' ') .replace(/^#+\s+/gm, '') .replace(/\*\*?|\*\*?/g, '') .replace(/^>\s*/gm, '') .replace(/```[\s\S]*?```/g, '') .replace(/`[^`]+`/g, '') .replace(/^---+$/gm, '') .replace(/<[^>]+>/g, '') .replace(/\n{3,}/g, '\n\n') .trim(); } function generateSayM4a(text, outputPath) { const tmpTxt = outputPath.replace(path.extname(outputPath), '.txt'); fs.writeFileSync(tmpTxt, text, 'utf-8'); try { // macOS say outputs AAC in .m4a directly with --file-format m4af execSync(`say -v "Samantha" --file-format m4af -f "${tmpTxt}" -o "${outputPath}"`, { stdio: 'ignore' }); fs.unlinkSync(tmpTxt); return true; } catch (e) { console.error(' say failed:', e.message); try { fs.unlinkSync(tmpTxt); } catch {} return false; } } function generateEspeakWav(text, outputPath) { const tmpWav = outputPath.replace('.mp3', '.wav'); try { execSync(`espeak -v en-us -s 150 -w "${tmpWav}" "${text.replace(/"/g, '\\"')}"`, { stdio: 'ignore' }); // Convert WAV to MP3 via lame or ffmpeg try { execSync(`lame -V 7 "${tmpWav}" "${outputPath}"`, { stdio: 'ignore' }); } catch { execSync(`ffmpeg -y -i "${tmpWav}" -codec:a libmp3lame -qscale:a 5 "${outputPath}"`, { stdio: 'ignore' }); } fs.unlinkSync(tmpWav); return true; } catch (e) { console.error(' espeak failed:', e.message); try { fs.unlinkSync(tmpWav); } catch {} return false; } } function main() { const engine = detectTtsEngine(); if (!engine) { console.error('No local TTS engine found. Install one of: say (macOS), espeak, piper'); process.exit(1); } console.log(`Using TTS engine: ${engine.type}`); const seed = JSON.parse(fs.readFileSync(SEED_PATH, 'utf-8')); let generated = 0; let failed = 0; for (const course of seed.courses) { for (const mod of course.modules || []) { if (!mod.audioPath) continue; const outPath = path.join(PUBLIC_DIR, mod.audioPath); // Skip if already exists and > 1KB if (fs.existsSync(outPath) && fs.statSync(outPath).size > 1024) { console.log(` SKIP (exists): ${mod.audioPath}`); continue; } // Ensure directory exists fs.mkdirSync(path.dirname(outPath), { recursive: true }); const text = stripMarkdown(mod.content || mod.keyTakeaway || ''); if (!text || text.length < 50) { console.log(` SKIP (no content): ${mod.title}`); continue; } // Truncate to ~4000 chars to keep file sizes reasonable const ttsText = text.slice(0, 4000); console.log(`Generating: ${mod.audioPath} (${ttsText.length} chars)`); const ok = engine.type === 'say' ? generateSayM4a(ttsText, outPath) : generateEspeakWav(ttsText, outPath); if (ok) { generated++; } else { failed++; } } } console.log(`\nDone: ${generated} generated, ${failed} failed`); } main();