Generate audio narrations of wiki pages so developers can listen during commutes, onboarding, or code review prep.
| Provider | Quality | Cost per 1M chars | Free Tier | API Key Needed |
|---|---|---|---|---|
| Edge TTS | Neural (good) | $0 (free) | Unlimited* | No |
| Google Standard | Basic | $4/1M chars | 4M chars/month | Yes |
| Google WaveNet | High | $16/1M chars | 1M chars/month | Yes |
| Google Neural2 | High | $16/1M chars | — | Yes |
| Google Journey | Conversational | ~$30/1M chars | — | Yes |
| OpenAI tts-1 | High | $15/1M chars | — | Yes |
| OpenAI tts-1-hd | Very high | $30/1M chars | — | Yes |
*Edge TTS has no official rate limits but could throttle if abused.
Measured from our 6 cached wikis:
| Wiki | Pages | Total Chars | Edge TTS Cost | Google WaveNet Cost |
|---|---|---|---|---|
| small-test-repo | 4 | 22K | $0 | $0.35 |
| vigilant-sanderson | 5 | 86K | $0 | $1.38 |
| deepwiki-open | 5 | 66K | $0 | $1.06 |
| claude-code | 10 | 1.8M* | $0 | $28.80* |
| gemini-cli | 13 | 1M* | $0 | $16.00* |
*These have abnormally large pages (1.5M chars in one page = data dump, not readable docs). After markdown-to-script conversion with truncation of code blocks, real narration content would be ~10-30K chars per page.
Realistic cost per typical wiki (5-10 pages, ~100K narration chars):
- Edge TTS: $0
- Google WaveNet: $1.60 (free if under 1M chars/month)
- OpenAI tts-1: $1.50
Edge TTS is free, neural-quality, and needs zero configuration. It's the right default. Users who want higher quality can switch to Google Cloud TTS or OpenAI via the existing model selection pattern.
Same pattern as the MCP server — new files only, no modifications to existing endpoints.
New files:
api/audio/
├── __init__.py
├── tts_engine.py # TTS provider abstraction (Edge/Google/OpenAI)
├── script_converter.py # Markdown wiki → spoken narration script
└── cache.py # Audio file caching (~/.adalflow/audio/)
src/components/
└── AudioPlayer.tsx # Frontend player component
Modified files:
api/api.py # Add 3 new endpoints (at bottom of file)
src/app/[owner]/[repo]/page.tsx # Add AudioPlayer next to ExportMenu
api/pyproject.toml # Add edge-tts dependency
Wiki Page (markdown)
→ script_converter.py strips code blocks, Mermaid diagrams,
tables, links → produces clean narration text
→ tts_engine.py chunks text (max 3000 chars per TTS call),
synthesizes each chunk, concatenates MP3 segments
→ cache.py saves to ~/.adalflow/audio/{owner}_{repo}_{page_id}_{lang}.mp3
→ API serves cached file or streams generation progress
→ AudioPlayer.tsx plays the MP3 with controls
Strips elements that don't make sense when spoken:
"""Convert wiki page markdown into a clean narration script."""
import re
def markdown_to_script(title: str, content: str) -> str:
"""Convert markdown wiki page content to a spoken narration script.
Strips code blocks, Mermaid diagrams, tables, raw HTML, image refs,
and reformats the remaining text for natural speech.
"""
text = content
# Remove Mermaid diagrams
text = re.sub(r'```mermaid[\s\S]*?```', '', text)
# Remove code blocks (replace with brief mention)
def replace_code_block(match):
lang = match.group(1) or "code"
return f"(A {lang} code example is shown in the documentation.) "
text = re.sub(r'```(\w*)\n[\s\S]*?```', replace_code_block, text)
# Remove inline code backticks but keep the text
text = re.sub(r'`([^`]+)`', r'\1', text)
# Remove images
text = re.sub(r'!\[.*?\]\(.*?\)', '', text)
# Convert links to just the text
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
# Remove HTML tags
text = re.sub(r'<[^>]+>', '', text)
# Remove table formatting (keep cell content)
text = re.sub(r'\|', ' ', text)
text = re.sub(r'[-:]{3,}', '', text)
# Convert headers to spoken transitions
text = re.sub(r'^#{1,6}\s+(.+)$', r'\n\1.\n', text, flags=re.MULTILINE)
# Remove bullet points / list markers
text = re.sub(r'^[\s]*[-*+]\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^[\s]*\d+\.\s+', '', text, flags=re.MULTILINE)
# Remove bold/italic markers
text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
text = re.sub(r'\*([^*]+)\*', r'\1', text)
text = re.sub(r'__([^_]+)__', r'\1', text)
# Collapse multiple newlines
text = re.sub(r'\n{3,}', '\n\n', text)
# Collapse multiple spaces
text = re.sub(r' +', ' ', text)
# Add title intro
script = f"{title}.\n\n{text.strip()}"
# Limit to reasonable narration length (~30K chars max, ~20 min of speech)
MAX_SCRIPT_CHARS = 30000
if len(script) > MAX_SCRIPT_CHARS:
script = script[:MAX_SCRIPT_CHARS]
# Cut at last sentence boundary
last_period = script.rfind('. ')
if last_period > MAX_SCRIPT_CHARS * 0.8:
script = script[:last_period + 1]
script += "\n\nThis concludes the narration for this page. See the full documentation for more details."
return scriptKey design decisions:
- Code blocks are replaced with "(A code example is shown in the documentation)" — not skipped silently
- Headers become natural spoken transitions with a period (pause)
- Links become just their text
- 30K char cap = ~20 minutes of audio max per page (prevents the 1.5M char outliers from burning money/time)
"""TTS provider abstraction. Adapter pattern matching existing LLM clients."""
import asyncio
import io
import os
import struct
from abc import ABC, abstractmethod
class TTSEngine(ABC):
"""Base class for TTS providers."""
@abstractmethod
async def synthesize(self, text: str, voice: str) -> bytes:
"""Convert text to audio bytes (MP3 format)."""
...
@abstractmethod
def list_voices(self) -> list[dict]:
"""Return available voices."""
...
class EdgeTTSEngine(TTSEngine):
"""Free TTS using Microsoft Edge's neural voices."""
VOICES = {
"en-US-AndrewMultilingualNeural": "Andrew (male, natural)",
"en-US-AvaMultilingualNeural": "Ava (female, natural)",
"en-US-BrianMultilingualNeural": "Brian (male, conversational)",
"en-US-EmmaMultilingualNeural": "Emma (female, conversational)",
}
DEFAULT_VOICE = "en-US-AndrewMultilingualNeural"
async def synthesize(self, text: str, voice: str = None) -> bytes:
import edge_tts
voice = voice or self.DEFAULT_VOICE
communicate = edge_tts.Communicate(text, voice)
audio_chunks = []
async for chunk in communicate.stream():
if chunk["type"] == "audio":
audio_chunks.append(chunk["data"])
return b"".join(audio_chunks)
def list_voices(self) -> list[dict]:
return [{"id": k, "name": v} for k, v in self.VOICES.items()]
class GoogleTTSEngine(TTSEngine):
"""Google Cloud TTS (WaveNet/Neural2 voices)."""
VOICES = {
"en-US-Neural2-D": "Neural2 D (male)",
"en-US-Neural2-C": "Neural2 C (female)",
"en-US-Wavenet-D": "WaveNet D (male)",
"en-US-Wavenet-F": "WaveNet F (female)",
}
DEFAULT_VOICE = "en-US-Neural2-D"
async def synthesize(self, text: str, voice: str = None) -> bytes:
from google.cloud import texttospeech
voice = voice or self.DEFAULT_VOICE
client = texttospeech.TextToSpeechClient()
synthesis_input = texttospeech.SynthesisInput(text=text)
voice_params = texttospeech.VoiceSelectionParams(
language_code="en-US",
name=voice,
)
audio_config = texttospeech.AudioConfig(
audio_encoding=texttospeech.AudioEncoding.MP3,
speaking_rate=1.0,
)
response = await asyncio.to_thread(
client.synthesize_speech,
input=synthesis_input,
voice=voice_params,
audio_config=audio_config,
)
return response.audio_content
def list_voices(self) -> list[dict]:
return [{"id": k, "name": v} for k, v in self.VOICES.items()]
class OpenAITTSEngine(TTSEngine):
"""OpenAI TTS (tts-1 / tts-1-hd)."""
VOICES = {
"alloy": "Alloy (neutral)",
"echo": "Echo (male)",
"fable": "Fable (male, British)",
"onyx": "Onyx (male, deep)",
"nova": "Nova (female)",
"shimmer": "Shimmer (female, warm)",
}
DEFAULT_VOICE = "nova"
def __init__(self, model: str = "tts-1"):
self.model = model
async def synthesize(self, text: str, voice: str = None) -> bytes:
from openai import OpenAI
voice = voice or self.DEFAULT_VOICE
client = OpenAI()
response = await asyncio.to_thread(
client.audio.speech.create,
model=self.model,
voice=voice,
input=text,
response_format="mp3",
)
return response.content
def list_voices(self) -> list[dict]:
return [{"id": k, "name": v} for k, v in self.VOICES.items()]
# ── Chunked synthesis (handles TTS API character limits) ──
MAX_CHUNK_CHARS = 3000 # Safe limit for all providers
def chunk_text(text: str, max_chars: int = MAX_CHUNK_CHARS) -> list[str]:
"""Split text into chunks at sentence boundaries."""
chunks = []
while len(text) > max_chars:
# Find the last sentence boundary within the limit
boundary = text.rfind('. ', 0, max_chars)
if boundary < max_chars * 0.5:
# No good sentence boundary, split at last space
boundary = text.rfind(' ', 0, max_chars)
if boundary < 0:
boundary = max_chars
chunks.append(text[:boundary + 1].strip())
text = text[boundary + 1:].strip()
if text:
chunks.append(text)
return chunks
async def synthesize_long_text(
engine: TTSEngine, text: str, voice: str = None
) -> bytes:
"""Synthesize text of any length by chunking and concatenating."""
chunks = chunk_text(text)
audio_parts = []
for chunk in chunks:
if chunk.strip():
audio = await engine.synthesize(chunk, voice)
audio_parts.append(audio)
return b"".join(audio_parts)
def get_engine(provider: str = "edge", model: str = None) -> TTSEngine:
"""Factory function for TTS engines."""
if provider == "edge":
return EdgeTTSEngine()
elif provider == "google":
return GoogleTTSEngine()
elif provider == "openai":
return OpenAITTSEngine(model=model or "tts-1")
else:
raise ValueError(f"Unknown TTS provider: {provider}")"""Audio file caching in ~/.adalflow/audio/."""
import hashlib
import os
import time
AUDIO_CACHE_DIR = os.path.expanduser("~/.adalflow/audio")
os.makedirs(AUDIO_CACHE_DIR, exist_ok=True)
def get_cache_key(
owner: str, repo: str, page_id: str,
provider: str, voice: str, language: str = "en"
) -> str:
"""Generate a deterministic cache filename."""
return f"{owner}_{repo}_{page_id}_{provider}_{voice}_{language}.mp3"
def get_cache_path(cache_key: str) -> str:
return os.path.join(AUDIO_CACHE_DIR, cache_key)
def is_cached(cache_key: str) -> bool:
path = get_cache_path(cache_key)
return os.path.exists(path) and os.path.getsize(path) > 0
def read_cached(cache_key: str) -> bytes | None:
path = get_cache_path(cache_key)
if not os.path.exists(path):
return None
with open(path, "rb") as f:
return f.read()
def write_cache(cache_key: str, audio_data: bytes) -> str:
path = get_cache_path(cache_key)
with open(path, "wb") as f:
f.write(audio_data)
return path
def get_cache_info(owner: str, repo: str) -> list[dict]:
"""List all cached audio files for a repo."""
prefix = f"{owner}_{repo}_"
entries = []
for fname in os.listdir(AUDIO_CACHE_DIR):
if fname.startswith(prefix) and fname.endswith(".mp3"):
path = os.path.join(AUDIO_CACHE_DIR, fname)
parts = fname.replace(".mp3", "").split("_")
entries.append({
"filename": fname,
"size_bytes": os.path.getsize(path),
"created_at": int(os.path.getmtime(path) * 1000),
})
return entriesThree new endpoints, appended to the bottom of api/api.py:
# ── Audio Generation Endpoints ──
@app.post("/api/audio/generate")
async def generate_audio(
owner: str = Query(...),
repo: str = Query(...),
page_id: str = Query(...),
repo_type: str = Query("github"),
language: str = Query("en"),
tts_provider: str = Query("edge"), # edge | google | openai
voice: str = Query(None), # provider-specific voice ID
):
"""Generate audio narration for a wiki page. Returns MP3.
Uses cached audio if available. Otherwise generates, caches, and returns.
"""
# 1. Load wiki page from cache
wiki_cache = await read_wiki_cache(owner, repo, repo_type, language)
if not wiki_cache:
raise HTTPException(404, f"No wiki cache for {owner}/{repo}")
page = wiki_cache.generated_pages.get(page_id)
if not page:
raise HTTPException(404, f"Page '{page_id}' not found")
# 2. Check audio cache
engine = get_engine(tts_provider)
voice = voice or engine.DEFAULT_VOICE
cache_key = get_cache_key(owner, repo, page_id, tts_provider, voice, language)
cached_audio = read_cached(cache_key)
if cached_audio:
return Response(content=cached_audio, media_type="audio/mpeg",
headers={"X-Audio-Cached": "true"})
# 3. Convert markdown to narration script
script = markdown_to_script(page.title, page.content)
# 4. Synthesize audio
audio_data = await synthesize_long_text(engine, script, voice)
# 5. Cache and return
write_cache(cache_key, audio_data)
return Response(content=audio_data, media_type="audio/mpeg",
headers={"X-Audio-Cached": "false"})
@app.get("/api/audio/status")
async def audio_status(
owner: str = Query(...),
repo: str = Query(...),
repo_type: str = Query("github"),
language: str = Query("en"),
):
"""Check which pages have cached audio for a repo."""
wiki_cache = await read_wiki_cache(owner, repo, repo_type, language)
if not wiki_cache:
raise HTTPException(404, f"No wiki cache for {owner}/{repo}")
page_status = {}
for page_id, page in wiki_cache.generated_pages.items():
# Check if any provider has cached audio
has_audio = any(
is_cached(get_cache_key(owner, repo, page_id, p, v, language))
for p, v in [
("edge", EdgeTTSEngine.DEFAULT_VOICE),
("google", GoogleTTSEngine.DEFAULT_VOICE),
("openai", OpenAITTSEngine.DEFAULT_VOICE),
]
)
page_status[page_id] = {
"title": page.title,
"has_audio": has_audio,
}
return page_status
@app.get("/api/audio/voices")
async def list_voices(tts_provider: str = Query("edge")):
"""List available voices for a TTS provider."""
engine = get_engine(tts_provider)
return {"provider": tts_provider, "voices": engine.list_voices()}A compact player that sits next to or below the wiki page title. Design priorities: minimal, non-intrusive, functional.
┌─────────────────────────────────────────────┐
│ 🔊 ▶ ━━━━━━━━━━━━━━━━━━━━━━ 3:24 / 12:15 │
│ 0.5x 1x 1.5x 2x ⬇ Download │
└─────────────────────────────────────────────┘
Component features:
- Play/pause toggle
- Progress bar with seek
- Current time / total duration
- Speed control (0.75x, 1x, 1.25x, 1.5x, 2x)
- Download button
- Loading state with spinner during generation
- Error state if generation fails
- "Generate Audio" button if no cached audio exists
Props interface:
interface AudioPlayerProps {
owner: string;
repo: string;
pageId: string;
pageTitle: string;
repoType?: string;
language?: string;
}Integration point in page.tsx:
Place the AudioPlayer inside the article header, below the page title, next to the importance badge. This is around line ~2382-2390 in src/app/[owner]/[repo]/page.tsx.
- Create
api/audio/__init__.py - Create
api/audio/script_converter.py— markdown → narration - Create
api/audio/tts_engine.py— Edge TTS engine (start with just Edge) - Create
api/audio/cache.py— MP3 file caching - Add
edge-ttstoapi/pyproject.toml - Add 3 endpoints to
api/api.py - Test:
curl "localhost:8001/api/audio/generate?owner=rtyley&repo=small-test-repo&page_id=<id>"should return MP3
- Create
src/components/AudioPlayer.tsx - Add AudioPlayer to wiki page view (
page.tsx) - Add "Listen" button that triggers generation if no cache
- Play cached audio immediately if available
- Add TTS provider selection (Edge/Google/OpenAI dropdown)
- Add voice selection
- Add speed control persistence (localStorage)
- Error handling for all edge cases
- Loading states
- Add
~/.adalflow/audioto volume mount in docker-compose.yml - Test in Docker container
- Ensure
edge-ttsworks inside the container (needs network)
| Feature | Why Deferred |
|---|---|
| NotebookLM-style podcast (two voices conversing) | Requires LLM to generate a conversation script first, then two TTS voices. Cool but complex — do it as v2 after single-voice narration works. |
| Full wiki narration (all pages as one audio) | Concatenating all pages is simple once single-page works. Add later as "Download Full Wiki Audio" button. |
| Waveform visualization | Nice but not necessary for v1. A simple progress bar is sufficient. |
| Real-time streaming | Edge TTS supports streaming, but caching the full MP3 is simpler and the result is reusable. |
| LLM-enhanced script | Using the LLM to rewrite markdown into a more natural narration script (expanding abbreviations, adding transitions). Good idea for v2 — for now, regex-based conversion is good enough. |
edge-tts = ">=7.0.0"That's the only new dependency for the default (free) provider.
For Google Cloud TTS (optional): google-cloud-texttospeech = ">=2.14.0"
For OpenAI TTS: already installed (openai package).
volumes:
- ~/.adalflow:/root/.adalflow # Already exists — includes audio/ subdirNo change needed — audio files go into ~/.adalflow/audio/ which is already inside the mounted volume.
| Scenario | Edge TTS | Google WaveNet | OpenAI tts-1 |
|---|---|---|---|
| 1 page (15K chars narration) | $0 | $0.24 | $0.23 |
| Full wiki (5 pages, ~75K chars) | $0 | $1.20 | $1.13 |
| 100 wikis/month (heavy usage) | $0 | $120 | $113 |
| 100 wikis/month under Google free tier | $0 | ~$0.80* | N/A |
*Google gives 1M free WaveNet chars/month. 100 wikis at 75K = 7.5M chars, so only 6.5M is billed.
Bottom line: Edge TTS makes this feature essentially free to run. Google/OpenAI are upgrade paths for users who want premium voices.