cd35b46393
- Added VOICE_QUALITY ratings for all engines (naturalness/calmness) - Azure SSML now uses mstts:express-as style="calm" for human-like delivery - Added --list-engines with quality rankings - Added --quality-check for A/B sample comparison - Updated README with quality-first guidance - Warns against Piper for production (robotic-ish) - Recommends Azure AriaNeural or ElevenLabs Rachel
334 lines
12 KiB
Python
334 lines
12 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",
|
|
}
|
|
|
|
# Quality ratings: naturalness, calmness, female voice availability
|
|
VOICE_QUALITY = {
|
|
"azure": {
|
|
"naturalness": 9.0,
|
|
"calmness": 8.5,
|
|
"female_voices": ["en-US-AriaNeural", "en-US-JennyNeural", "en-GB-SoniaNeural"],
|
|
"free_tier": "500K chars/month",
|
|
"setup": "Azure account + key",
|
|
"recommended": True,
|
|
},
|
|
"elevenlabs": {
|
|
"naturalness": 9.5,
|
|
"calmness": 9.0,
|
|
"female_voices": ["Rachel", "Bella", "Antoni"],
|
|
"free_tier": "10K chars/month",
|
|
"setup": "API key",
|
|
"recommended": True,
|
|
},
|
|
"piper": {
|
|
"naturalness": 6.5,
|
|
"calmness": 7.0,
|
|
"female_voices": ["amy", "libritts-high"],
|
|
"free_tier": "Unlimited",
|
|
"setup": "Download model files",
|
|
"recommended": False, # Good but noticeably synthetic
|
|
},
|
|
"google": {
|
|
"naturalness": 8.0,
|
|
"calmness": 7.5,
|
|
"female_voices": ["en-US-Neural2-F", "en-US-Neural2-C"],
|
|
"free_tier": "1M chars/month",
|
|
"setup": "GCP account + key",
|
|
"recommended": True,
|
|
},
|
|
}
|
|
|
|
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 — human-like neural voice."""
|
|
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/")
|
|
print("\nRecommended voice for calm female tone: en-US-AriaNeural")
|
|
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 natural, calm, human-like delivery
|
|
# Uses expressive style with pauses and gentle prosody
|
|
ssml = f"""<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xmlns:mstts='http://www.w3.org/2001/mstts' xml:lang='en-US'>
|
|
<voice name='{voice}'>
|
|
<mstts:express-as style="calm">
|
|
<prosody rate="-5%" pitch="-2%">
|
|
{text}
|
|
</prosody>
|
|
</mstts:express-as>
|
|
</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 with quality ratings")
|
|
parser.add_argument("--quality-check", action="store_true", help="Generate a sample and compare engine quality")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.quality_check:
|
|
sample_text = "When a Muslim performs wudu and washes his face, every sin he committed with his eyes is washed away."
|
|
print("Generating quality comparison sample...")
|
|
print(f"Sample text: \"{sample_text}\"")
|
|
print("")
|
|
for engine in ["azure", "elevenlabs", "piper"]:
|
|
print(f"--- {engine.upper()} ---")
|
|
info = VOICE_QUALITY[engine]
|
|
print(f"Naturalness: {info['naturalness']}/10 | Calmness: {info['calmness']}/10")
|
|
path = Path(f"/tmp/quality-check-{engine}.mp3")
|
|
engines = {
|
|
"azure": generate_azure,
|
|
"piper": generate_piper,
|
|
"elevenlabs": generate_elevenlabs,
|
|
}
|
|
if engine in engines:
|
|
ok = engines[engine](sample_text, path)
|
|
if ok and path.exists():
|
|
size = path.stat().st_size
|
|
print(f"Output: {path} ({size} bytes)")
|
|
print("")
|
|
print("Compare the three files. Choose the engine that sounds most natural.")
|
|
return
|
|
|
|
if args.list_engines:
|
|
print("Available TTS engines — ranked by naturalness (anti-robot):")
|
|
print("")
|
|
for engine, info in sorted(VOICE_QUALITY.items(), key=lambda x: -x[1]["naturalness"]):
|
|
rec = "★ RECOMMENDED" if info["recommended"] else " (robotic-ish)"
|
|
print(f" {engine:<12} naturalness: {info['naturalness']}/10 calmness: {info['calmness']}/10 {rec}")
|
|
print(f" free: {info['free_tier']}")
|
|
print(f" voices: {', '.join(info['female_voices'])}")
|
|
print("")
|
|
print("Avoid 'Stephen Hawking' sound:")
|
|
print(" • Use Azure (AriaNeural) or ElevenLabs (Rachel) for best quality")
|
|
print(" • Piper is free but noticeably synthetic — acceptable for testing")
|
|
print(" • Always use Neural voices, never Standard voices")
|
|
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()
|