cb22804a1f
- 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
265 lines
8.9 KiB
Python
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()
|