#!/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""" {text} """ 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()