Files
wmj2024 cd35b46393 feat: audio quality improvements — anti-robot TTS pipeline
- 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
2026-06-28 02:09:10 +08:00

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()