feat(learn): add 3 new courses — Ramadan Fasting, Zakat, Hajj & Umrah
- 15 new modules with 2000-3000 char markdown content - 1 quiz question per module with explanation - audioPath set to .m4a for all 15 modules (TTS-ready) - Added local TTS generation script (scripts/generate-tts-local.js) - Added .gitignore for generated audio files - Total: 7 courses, 32 modules in seed-learn.json Note: Audio files are generated locally via generate-tts-local.js. They are excluded from git (see .gitignore) due to large binary size. For production MP3s, use Azure/ElevenLabs via Gitea's generate-audio.py.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
#!/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('.m4a', '.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();
|
||||
Reference in New Issue
Block a user