feat: Daily Fiqh Module 1 — Purification & Prayer
- 5 micro-lessons (200-400 words each) - Standardized YAML frontmatter with audio metadata - Quiz with 5 questions + explanations - TTS pipeline script (Azure/Piper/ElevenLabs) - Lesson template for future modules - Calibrated read times and audio durations
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TTS Audio Generation Pipeline for FalahMobile Content
|
||||
Supports: Azure Speech (free tier), Piper TTS (local/free), Google Cloud, ElevenLabs
|
||||
|
||||
Usage:
|
||||
python scripts/generate-audio.py --course daily-fiqh-beginner --engine azure
|
||||
python scripts/generate-audio.py --course daily-fiqh-beginner --engine piper
|
||||
python scripts/generate-audio.py --lesson path/to/lesson.md --engine azure
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configuration
|
||||
COURSES_DIR = Path(__file__).parent.parent / "courses"
|
||||
DEFAULT_VOICE = {
|
||||
"azure": "en-US-AriaNeural",
|
||||
"google": "en-US-Neural2-F",
|
||||
"elevenlabs": "Rachel",
|
||||
"piper": "amy",
|
||||
}
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
"""Convert markdown to clean text for TTS."""
|
||||
# Remove YAML frontmatter
|
||||
text = re.sub(r'^---\n.*?---\n', '', text, flags=re.DOTALL)
|
||||
# Remove emoji
|
||||
text = re.sub(r'[\U0001F300-\U0001F9FF]', '', text)
|
||||
# Remove markdown headers
|
||||
text = re.sub(r'^#+\s+', '', text, flags=re.MULTILINE)
|
||||
# Remove bold/italic markers
|
||||
text = re.sub(r'\*\*?|\*\*?', '', text)
|
||||
# Remove blockquotes markers but keep text
|
||||
text = re.sub(r'^>\s*', '', text, flags=re.MULTILINE)
|
||||
# Remove code blocks
|
||||
text = re.sub(r'```.*?```', '', text, flags=re.DOTALL)
|
||||
# Remove inline code
|
||||
text = re.sub(r'`[^`]+`', '', text)
|
||||
# Remove horizontal rules
|
||||
text = re.sub(r'^---+', '', text, flags=re.MULTILINE)
|
||||
# Remove HTML tags
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
# Remove extra whitespace
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
return text.strip()
|
||||
|
||||
def generate_azure(text: str, output_path: Path, voice: str = None):
|
||||
"""Generate audio using Azure Speech SDK."""
|
||||
voice = voice or DEFAULT_VOICE["azure"]
|
||||
|
||||
# Check for Azure key
|
||||
key = os.environ.get("AZURE_SPEECH_KEY")
|
||||
region = os.environ.get("AZURE_SPEECH_REGION", "eastus")
|
||||
|
||||
if not key:
|
||||
print("Error: Set AZURE_SPEECH_KEY environment variable")
|
||||
print("Get free key at: https://azure.microsoft.com/en-us/services/cognitive-services/speech/")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import azure.cognitiveservices.speech as speechsdk
|
||||
except ImportError:
|
||||
print("Installing azure-cognitiveservices-speech...")
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", "azure-cognitiveservices-speech"])
|
||||
import azure.cognitiveservices.speech as speechsdk
|
||||
|
||||
speech_config = speechsdk.SpeechConfig(subscription=key, region=region)
|
||||
speech_config.speech_synthesis_voice_name = voice
|
||||
|
||||
# SSML for calm, gentle pacing
|
||||
ssml = f"""<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>
|
||||
<voice name='{voice}'>
|
||||
<prosody rate="-10%" pitch="-5%">
|
||||
{text}
|
||||
</prosody>
|
||||
</voice>
|
||||
</speak>"""
|
||||
|
||||
audio_config = speechsdk.audio.AudioOutputConfig(filename=str(output_path))
|
||||
synthesizer = speechsdk.SpeechSynthesizer(speech_config=speech_config, audio_config=audio_config)
|
||||
|
||||
result = synthesizer.speak_ssml_async(ssml).get()
|
||||
|
||||
if result.reason == speechsdk.ResultReason.SynthesizingAudioCompleted:
|
||||
print(f" Generated: {output_path}")
|
||||
return True
|
||||
else:
|
||||
print(f" Error: {result.reason}")
|
||||
return False
|
||||
|
||||
def generate_piper(text: str, output_path: Path, voice: str = None):
|
||||
"""Generate audio using Piper TTS (local, completely free)."""
|
||||
voice = voice or DEFAULT_VOICE["piper"]
|
||||
|
||||
piper_dir = Path.home() / ".piper"
|
||||
model_path = piper_dir / f"{voice}.onnx"
|
||||
|
||||
if not model_path.exists():
|
||||
print(f"Piper model not found: {model_path}")
|
||||
print("Download models from: https://github.com/rhasspy/piper/releases")
|
||||
print(f"Expected at: {piper_dir}/{voice}.onnx")
|
||||
return False
|
||||
|
||||
# Write text to temp file
|
||||
temp_text = output_path.with_suffix(".txt")
|
||||
temp_text.write_text(text, encoding="utf-8")
|
||||
|
||||
# Run piper
|
||||
cmd = [
|
||||
"piper",
|
||||
"--model", str(model_path),
|
||||
"--output_file", str(output_path),
|
||||
"--data-dir", str(piper_dir),
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, input=text, text=True, capture_output=True)
|
||||
temp_text.unlink(missing_ok=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f" Generated: {output_path}")
|
||||
return True
|
||||
else:
|
||||
print(f" Error: {result.stderr}")
|
||||
return False
|
||||
|
||||
def generate_elevenlabs(text: str, output_path: Path, voice: str = None):
|
||||
"""Generate audio using ElevenLabs API."""
|
||||
voice = voice or DEFAULT_VOICE["elevenlabs"]
|
||||
key = os.environ.get("ELEVENLABS_API_KEY")
|
||||
|
||||
if not key:
|
||||
print("Error: Set ELEVENLABS_API_KEY environment variable")
|
||||
sys.exit(1)
|
||||
|
||||
import requests
|
||||
|
||||
url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice}"
|
||||
headers = {
|
||||
"Accept": "audio/mpeg",
|
||||
"Content-Type": "application/json",
|
||||
"xi-api-key": key,
|
||||
}
|
||||
data = {
|
||||
"text": text,
|
||||
"model_id": "eleven_monolingual_v1",
|
||||
"voice_settings": {
|
||||
"stability": 0.75,
|
||||
"similarity_boost": 0.75,
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(url, json=data, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
output_path.write_bytes(response.content)
|
||||
print(f" Generated: {output_path}")
|
||||
return True
|
||||
else:
|
||||
print(f" Error: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
def generate_web_speech(text: str, output_path: Path):
|
||||
"""Generate audio using browser Web Speech API (via Node/playwright)."""
|
||||
print("Web Speech API generates audio in-browser, not pre-generated.")
|
||||
print("The app should use the text directly with Web Speech API.")
|
||||
return True
|
||||
|
||||
def process_lesson(lesson_path: Path, engine: str, voice: str = None):
|
||||
"""Process a single lesson markdown file into audio."""
|
||||
print(f"Processing: {lesson_path.name}")
|
||||
|
||||
# Read and clean markdown
|
||||
md_text = lesson_path.read_text(encoding="utf-8")
|
||||
clean_text = strip_markdown(md_text)
|
||||
|
||||
# Generate audio filename
|
||||
audio_path = lesson_path.with_suffix(".mp3")
|
||||
|
||||
# Dispatch to engine
|
||||
engines = {
|
||||
"azure": generate_azure,
|
||||
"piper": generate_piper,
|
||||
"elevenlabs": generate_elevenlabs,
|
||||
"web": generate_web_speech,
|
||||
}
|
||||
|
||||
if engine not in engines:
|
||||
print(f"Unknown engine: {engine}")
|
||||
return False
|
||||
|
||||
return engines[engine](clean_text, audio_path, voice)
|
||||
|
||||
def process_course(course_id: str, engine: str, voice: str = None):
|
||||
"""Process all lessons in a course."""
|
||||
course_dir = COURSES_DIR / course_id
|
||||
|
||||
if not course_dir.exists():
|
||||
print(f"Course not found: {course_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
manifest_path = course_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
print(f"\nCourse: {manifest['title']}")
|
||||
print(f"Modules: {manifest['total_modules']}")
|
||||
|
||||
# Find all lesson markdown files
|
||||
lessons = sorted(course_dir.rglob("lesson-*.md"))
|
||||
|
||||
if not lessons:
|
||||
print("No lessons found")
|
||||
return
|
||||
|
||||
print(f"\nGenerating audio for {len(lessons)} lessons...")
|
||||
print(f"Engine: {engine}")
|
||||
print(f"Voice: {voice or DEFAULT_VOICE.get(engine, 'default')}")
|
||||
print("-" * 50)
|
||||
|
||||
success = 0
|
||||
for lesson in lessons:
|
||||
if process_lesson(lesson, engine, voice):
|
||||
success += 1
|
||||
|
||||
print("-" * 50)
|
||||
print(f"Done: {success}/{len(lessons)} lessons generated")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate TTS audio for FalahMobile content")
|
||||
parser.add_argument("--course", help="Course ID to process all lessons")
|
||||
parser.add_argument("--lesson", help="Path to single lesson markdown file")
|
||||
parser.add_argument("--engine", choices=["azure", "piper", "elevenlabs", "web"],
|
||||
default="azure", help="TTS engine")
|
||||
parser.add_argument("--voice", help="Voice ID (engine-specific)")
|
||||
parser.add_argument("--list-engines", action="store_true", help="List available engines and voices")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_engines:
|
||||
print("Available engines:")
|
||||
print(" azure - Microsoft Azure Speech (free: 500K chars/mo)")
|
||||
print(" Voice: en-US-AriaNeural (default)")
|
||||
print(" piper - Local open-source TTS (unlimited, free)")
|
||||
print(" Voice: amy (default, calm female)")
|
||||
print(" elevenlabs - Premium quality (free: 10K chars/mo)")
|
||||
print(" Voice: Rachel (default)")
|
||||
print(" web - Web Speech API (in-browser, no pre-gen)")
|
||||
return
|
||||
|
||||
if args.course:
|
||||
process_course(args.course, args.engine, args.voice)
|
||||
elif args.lesson:
|
||||
lesson_path = Path(args.lesson)
|
||||
process_lesson(lesson_path, args.engine, args.voice)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user