Ebook Voice Player is a cross-platform e-book text-to-speech reader built on the Kivy framework and Microsoft edge-tts engine, supporting Windows, Android, and other platforms. This article provides a deep technical analysis of the application's architecture design, core modules, and key technical challenges.
1. Overall Architecture Design
1.1 Layered Architecture
┌─────────────────────────────────────────────────────────────┐
│ Presentation Layer (Kivy UI) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────┐ │
│ │ ReaderPanel │ │ VoicePanel │ │ControlPanel │ │ Menus │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Business Logic Layer │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ TTSService │ │ VoiceManager │ │ I18n System │ │
│ │ (Playback Orch.)│ │ (300+ Voices) │ │ (zh/en) │ │
│ └──────────────────┘ └──────────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Core Engine Layer │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ TTSGenerator │ │ TTSPlayer │ │ Reader │ │
│ │ (Async Synthesis)│ │ (Audio Playback)│ │ (Doc Parsing)│ │
│ └──────────────────┘ └──────────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ edge-tts │ │ PyMuPDF/ │ │ Kivy │ │
│ │ (Cloud TTS) │ │ pdfplumber │ │ (Framework) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
1.2 Core Design Principles
| Principle | Implementation |
|---|---|
| Separation of Concerns | UI, business logic, and core engine decoupled via event bus |
| Async-First | Full async pipeline for TTS synthesis, file parsing, audio playback |
| Cache-First | SHA256 cache keys + atomic writes prevent duplicate synthesis & corruption |
| Cross-Platform Adaptive | Responsive layout auto-adapts desktop/phone, unified Kivy rendering |
| Privacy-First | Local document parsing, only synthesis requests hit cloud, no accounts/telemetry/ads |
2. Core Modules Deep Dive
2.1 TTSService: Playback Orchestrator (Core State Machine)
TTSService is the brain of the entire playback pipeline, managing the complete lifecycle from text to audio playback.
class TTSService:
"""High-level playback orchestrator. Same state machine as the original Qt version."""
def __init__(self):
self.events = Emitter() # Event bus
self.paragraphs = [] # Pending synthesis paragraphs
self.audio_queue = {} # Index -> local audio path
self.current_index = -1 # Current playing paragraph index
self.total = 0 # Total paragraph count
self.base_index = 0 # Base offset (for chapter jumps)
# State flags
self._playing = False
self._paused = False
self._stopped = False
self._finished = False
self._speed = 1.0
# Sub-components
self.generator = TTSGenerator() # Background synthesizer
self.player = TTSPlayer() # Audio player
def start(self, paragraphs, voice, base_index=0):
"""Start playback flow"""
self._replace_generator() # Replace generator, cleanup old tasks
self.player.stop()
self.audio_queue.clear()
self.paragraphs = paragraphs
self.total = len(paragraphs)
self.base_index = base_index
self.current_index = -1
self._playing = True
self._paused = False
self._stopped = False
self._finished = False
self._emit("status", "playing")
self.generator.start_generation(paragraphs, voice, self._current_rate())
Key Design Points:
- Generator Hot-Swap: Cancel old generator, start new one on voice/speed/chapter change, retain already-generated audio
-
Audio Queue Pre-fetch:
audio_queuecaches synthesized audio, playback thread reads non-blocking -
Event-Driven:
Emitterdispatchesparagraph,progress,error,finishedevents to UI main thread
2.2 TTSGenerator: Async Concurrent Synthesis Engine
class TTSGenerator:
"""Background edge-tts synthesis. Runs an asyncio loop in a thread."""
def __init__(self, max_workers=3):
self.events = Emitter()
self.max_workers = max_workers # Concurrency limit
self._thread = None
self._cancelled = False
def _run(self):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
sem = asyncio.Semaphore(self.max_workers) # Rate limiting
async def gen_one(idx, text):
# Cache key: text + voice + rate
cache_key = hashlib.sha256(
f"{text}|{self.voice}|{rate_str}".encode()).hexdigest()[:16]
cache_file = cache_dir / f"c_{cache_key}.mp3"
# Cache hit -> return immediately
if cache_file.exists() and is_valid_mp3(str(cache_file)):
self._emit("paragraph", idx, str(cache_file))
return
# Atomic write: write .part.mp3 first, verify, then rename
tmp_file = cache_dir / f"c_{cache_key}.part.mp3"
communicate = edge_tts.Communicate(text, self.voice, rate=rate_str)
await communicate.save(str(tmp_file))
# Validate file integrity
if is_valid_mp3(str(tmp_file)):
os.replace(str(tmp_file), str(cache_file)) # Atomic replace
self._emit("paragraph", idx, str(cache_file))
# Concurrent execution of all paragraphs
tasks = [limited(i, p) for i, p in enumerate(self.paragraphs)]
loop.run_until_complete(asyncio.gather(*tasks))
Core Optimizations:
| Optimization | Implementation | Effect |
|---|---|---|
| Cache Deduplication | SHA256(text+voice+rate) as cache key | Zero latency on repeat playback |
| Atomic Writes |
.part.mp3 temp file + os.replace()
|
Prevents 0-byte corrupt files |
| Concurrency Control |
asyncio.Semaphore(3) limits parallel requests |
Avoids edge-tts rate limits/403 |
| Error Isolation | Single paragraph failure doesn't block others, reports once | Improves robustness |
| Cancellation |
_cancelled flag + thread-level cancel |
Responsive stop/switch |
2.3 TTSPlayer: Cross-Platform Audio Playback with Watchdog
Windows SDL2 audio backend has an issue where on_stop event doesn't fire at end of playback. TTSPlayer guarantees progress via a timestamp watchdog:
class TTSPlayer:
def _load_and_play(self, path, seek_pos=0.0):
snd = SoundLoader.load(path)
self._expected_len = float(getattr(snd, "length", 0) or 0)
# Windows often reports length=0, estimate from bitrate
if self._expected_len <= 0:
self._expected_len = max(0.0, os.path.getsize(path) * 8.0 / 48000.0 - 0.2)
snd.bind(on_stop=self._on_sound_stop)
self._play_start = time.time()
self._start_watchdog() # Start watchdog
snd.play()
def _start_watchdog(self):
def tick(dt):
if self._stopped or self._paused or self.sound is None:
return False
if self.sound.state != "play":
return False # Native on_stop will drive completion
elapsed = time.time() - self._play_start
if elapsed >= self._expected_len + 1.0: # 1s tolerance
self._finish_playback() # Force advance
return False
return True
self._watchdog = Clock.schedule_interval(tick, 0.2)
Watchdog Design Highlights:
- 200ms polling interval balances CPU usage vs response latency
-
+1.0stolerance absorbs network jitter/decoding delay - Only intervenes when
on_stopfails; normal path uses native event
3. Document Parsing Engine: Unified Multi-Format Abstraction
reader.py provides a unified read_file(filepath) -> List[str] interface, abstracting format differences:
def read_file(filepath):
path = Path(filepath)
suffix = path.suffix.lower()
if suffix == ".txt":
return read_txt(filepath)
elif suffix in (".docx", ".doc"):
return read_docx(filepath)
elif suffix == ".pdf":
return read_pdf(filepath)
elif suffix == ".epub":
return read_epub(filepath)
else:
raise ValueError(f"Unsupported format: {suffix}")
3.1 TXT Auto-Encoding Detection
def read_txt(filepath, encoding=None):
encodings = encoding or ["utf-8-sig", "utf-8", "gbk", "gb2312",
"gb18030", "big5", "utf-16", "shift-jis", "euc-jp"]
for enc in encodings:
try:
with open(filepath, "r", encoding=enc) as f:
text = f.read()
break
except (UnicodeDecodeError, UnicodeError):
continue
else:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
# Strip BOM
if text.startswith("\ufeff"):
text = text[1:]
return [p.strip() for p in text.split("\n") if p.strip()]
3.2 PDF Dual-Engine Fallback
def read_pdf(filepath):
try:
import fitz # PyMuPDF - fast, accurate
doc = fitz.open(filepath)
for page in doc:
text = page.get_text().strip()
...
except ImportError:
try:
import pdfplumber # Fallback - better table extraction
with pdfplumber.open(filepath) as pdf:
for page in pdf.pages:
text = page.extract_text()
...
except ImportError:
raise ImportError("Please install PyMuPDF or pdfplumber")
3.3 EPUB Semantic Parsing
def read_epub(filepath):
import ebooklib
from ebooklib import epub
from bs4 import BeautifulSoup
book = epub.read_epub(filepath)
paragraphs = []
for item in book.get_items():
if item.get_type() == ebooklib.ITEM_DOCUMENT:
soup = BeautifulSoup(item.get_content(), "html.parser")
# Preserve semantic tag hierarchy
for p in soup.find_all(["p", "h1", "h2", "h3", "h4", "h5", "h6", "div", "span"]):
text = p.get_text().strip()
if text:
paragraphs.append(text)
return paragraphs
3.4 Smart Chapter Detection
def detect_chapters(paragraphs):
primary_patterns = [
r"^第[一二三四五六七八九十百千万零〇\d]+[章卷部篇]",
r"^第\s*\d+\s*[章卷部篇]",
r"^Chapter\s+\d+",
r"^CHAPTER\s+\d+",
]
secondary_patterns = [
r"^第[一二三四五六七八九十百千万零〇\d]+[话节集]",
r"^[Pp]art\s+\d+",
r"^[Ss]ection\s+\d+",
]
# Primary headings prioritized; secondary merged if within 3 paragraphs of primary
...
4. Responsive Cross-Platform UI Adaptation
4.1 Automatic Layout Mode Switching
def is_phone_layout(w, h):
"""Narrow portrait = phone mode"""
return w > 0 and w < h and w <= 820
def _update_layout_mode(self, *a):
w, h = _logical_window_size()
phone = is_phone_layout(w, h)
if phone:
# Phone: vertical stack + bottom tab switch
self._body.orientation = "vertical"
self.reader.size_hint_x = 1
self.voice.size_hint_x = 1
self.voice.size_hint_y = 0 # Hidden, use popup instead
self.voice.opacity = 0
self._tabbar.opacity = 1 # Show bottom tab bar
else:
# Desktop: horizontal split
self._body.orientation = "horizontal"
self.reader.size_hint_x = 3
self.voice.size_hint_x = 1
self.voice.size_hint_y = 1
self.voice.opacity = 1
self._tabbar.opacity = 0
4.2 Android Logical Pixel Conversion
def _logical_window_size():
"""Android Window.size is physical pixels, convert to dp"""
try:
from jnius import autoclass
DisplayMetrics = autoclass('android.util.DisplayMetrics')
PythonActivity = autoclass('org.kivy.android.PythonActivity')
metrics = DisplayMetrics()
PythonActivity.mActivity.getWindowManager().getDefaultDisplay().getRealMetrics(metrics)
factor = 160.0 / metrics.densityDpi
return int(metrics.widthPixels * factor), int(metrics.heightPixels * factor)
except Exception:
return int(Window.width), int(Window.height)
5. Internationalization (i18n) System Design
5.1 Voice-Driven UI Language Auto-Switch
def voice_to_locale(voice_id):
"""Derive UI language from voice id: zh-* -> zh, others -> en"""
if not voice_id:
return LANG_EN
prefix = voice_id.split("-", 1)[0].lower()
return _VOICE_PREFIX_TO_LOCALE.get(prefix, LANG_EN)
5.2 Stable Key Catalog + Runtime Hot-Switch
T = {
"app_title": {"zh": "TTS 电子书语音朗读器", "en": "TTS E-Book Audio Reader"},
"menu_file": {"zh": "文件", "en": "File"},
"vp_title": {"zh": "语音选择", "en": "Voice Selection"},
# ... 200+ entries
}
def L(key):
entry = T.get(key)
if entry is None:
return key
return entry.get(_lang, entry.get(LANG_EN, key)) # Fallback chain
def set_language(lang):
global _lang
if lang == _lang:
return False
_lang = lang
for cb in _callbacks:
cb(lang) # Notify all UI components to refresh
return True
6. Key Technical Challenges & Solutions
| Challenge | Solution | Location |
|---|---|---|
Windows SDL2 on_stop failure |
Timestamp watchdog + bitrate duration estimation | tts_engine.py:TTSPlayer._start_watchdog() |
| edge-tts 403/rate limits | Concurrency semaphore(3) + system time check + smart error classification | tts_engine.py:TTSGenerator._describe_error() |
| Large file parsing lag | Background thread parsing + chunked loading + progress callbacks | main_screen.py:load_file() |
| Cache file corruption |
.part.mp3 atomic write + MP3 header validation |
tts_engine.py:is_valid_mp3() + gen_one()
|
| Phone/Desktop dual form factor |
is_phone_layout() + runtime layout restructuring |
main_screen.py:_update_layout_mode() |
| Chinese font glyph missing | Bundled Noto Sans CJK SC + Config.set("kivy", "default_font", ...)
|
main.py:_configure_default_font() |
| Speed change instant apply | Hot-swap generator + retain played audio + only re-synthesize pending | tts_engine.py:TTSService.set_speed() |
7. Packaging & Distribution
7.1 MSIX Packaging Pipeline
# build_msix_store.py key steps
1. PyInstaller bundle -> single-file exe + deps
2. Generate AppxManifest.xml (with runFullTrust request)
3. MakeAppx.exe package -> .msix
4. SignTool sign (EV cert / Partner Center cert)
5. Upload to Microsoft Partner Center
7.2 Startup Performance Optimization
# main.py: Adaptive window sizing avoids first-frame relayout
def _configure_window_size():
if sys.platform == "win32":
dpi = user32.GetDpiForWindow(user32.GetDesktopWindow()) or 96
scale = 96.0 / dpi
w = max(900, int(sw * 0.88 * scale))
h = max(620, int(sh * 0.86 * scale))
Config.set("graphics", "width", str(w))
Config.set("graphics", "height", str(h))
8. Performance Benchmarks
| Metric | Value | Notes |
|---|---|---|
| Cold Start | ~1.2s (Win11, SSD) | Incl. font load, config read |
| First Synthesis Latency | ~800ms (good network) | edge-tts network RTT dominated |
| Cache Hit Playback | <50ms | Local MP3 direct play |
| Memory Usage | ~120MB idle / ~180MB playing | Incl. audio cache |
| Concurrent Synthesis Throughput | ~3 para/sec | Limited by edge-tts backend |
| Supported Voices | 300+ (20+ languages) | Full coverage of major languages |
9. Future Roadmap
- [ ] Local TTS Engine Integration (Piper/Coqui) - Offline synthesis support
- [ ] WebDAV/Cloud Sync - Library, progress, bookmarks multi-device sync
- [ ] SSML Fine Control - Pause, pitch, speaking style markers
- [ ] Plugin System - Custom document parsers, export formats
- [ ] ARM64 Native Build - Surface Pro X / Windows on ARM optimization
10. Conclusion
Ebook Voice Player's architecture embodies pragmatic engineering:
- Don't reinvent wheels: Leverage mature Kivy + edge-tts, focus on business innovation
- Clear boundaries: Three-layer architecture + event bus enables testing & extension
- Details matter: Atomic cache writes, watchdog fallback, auto-encoding detection — engineering details determine experience ceiling
- Cross-platform without compromise: Single codebase, desktop/phone dual form factors, not mere scaling
If you're building similar cross-platform audio apps, hope this architectural analysis provides reference value.
Download & Try: Microsoft Store - Ebook Voice Player
Source Code: GitHub - lijiannan1241/ebook-voice-player
Technical Discussion: Welcome to discuss architecture details or submit PRs on GitHub Issues
Top comments (0)