Files
falahmobile-content/scripts/generate-audio.py
T
wmj2024 cb22804a1f 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
2026-06-28 02:04:27 +08:00

265 lines
8.9 KiB
Python

#!/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()