#!/usr/bin/env python3 """ Audiobook Generator with Web GUI - Complete Version with Cover Art Preview Uses espeak-ng for TTS and Calibre for ebook conversion Author: AI Assistant Date: 2025-06-05 """ import os import sys import subprocess import tempfile import shutil import json import re from pathlib import Path from typing import List, Dict, Optional, Tuple import threading import time import wave import io import base64 from dataclasses import dataclass from urllib.parse import quote import zipfile import xml.etree.ElementTree as ET from html.parser import HTMLParser import html2text from PIL import Image import mutagen from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB from mutagen.mp4 import MP4, MP4Cover from mutagen.flac import FLAC from mutagen.oggvorbis import OggVorbis import gradio as gr from pydub import AudioSegment from pydub.utils import make_chunks # Global variables for progress tracking current_progress = {"value": 0, "status": "Ready", "total": 0} generation_thread = None stop_generation = False @dataclass class AudiobookConfig: """Configuration for audiobook generation""" input_file: str output_dir: str voice: str speed: int pitch: int volume: int output_format: str chapter_split: bool include_cover: bool language: str book_title: str = "Audiobook" author: str = "Unknown Author" class EspeakVoiceManager: """Manages espeak-ng voices and languages""" @staticmethod def get_available_voices() -> Dict[str, List[Dict]]: """Get all available espeak-ng voices organized by language""" try: result = subprocess.run(['espeak-ng', '--voices'], capture_output=True, text=True, check=True) voices_by_lang = {} lines = result.stdout.strip().split('\n')[1:] # Skip header for line in lines: if not line.strip(): continue parts = line.split() if len(parts) >= 4: lang_code = parts[1] voice_name = parts[3] if len(parts) > 3 else parts[1] gender = parts[2] if len(parts) > 2 else 'U' if lang_code not in voices_by_lang: voices_by_lang[lang_code] = [] voices_by_lang[lang_code].append({ 'name': voice_name, 'code': lang_code, 'gender': gender, 'display': f"{voice_name} ({lang_code}) [{gender}]" }) return voices_by_lang except Exception as e: print(f"Error getting voices: {e}") return {"en": [{"name": "en", "code": "en", "gender": "U", "display": "English (en) [U]"}]} @staticmethod def get_voice_list() -> List[str]: """Get a flat list of all voices for dropdown""" voices_by_lang = EspeakVoiceManager.get_available_voices() voice_list = [] for lang, voices in sorted(voices_by_lang.items()): for voice in voices: voice_list.append(voice['display']) return voice_list @staticmethod def parse_voice_selection(voice_display: str) -> Tuple[str, str]: """Parse voice selection to get voice name and language code""" # Extract voice name and language code from display format # Format: "voice_name (lang_code) [gender]" match = re.match(r'([^(]+)\s*\(([^)]+)\)', voice_display) if match: voice_name = match.group(1).strip() lang_code = match.group(2).strip() return voice_name, lang_code return "en", "en" class EPUBParser: """Direct EPUB parser as fallback to Calibre""" @staticmethod def extract_text_and_metadata(epub_path: str) -> Tuple[str, Optional[str], str, str]: """Extract text, cover, title, and author from EPUB file""" try: with zipfile.ZipFile(epub_path, 'r') as zip_file: # Find OPF file container_path = 'META-INF/container.xml' if container_path not in zip_file.namelist(): raise Exception("Invalid EPUB: missing container.xml") container_xml = zip_file.read(container_path).decode('utf-8') container_root = ET.fromstring(container_xml) # Find OPF file path opf_path = None for rootfile in container_root.findall('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'): opf_path = rootfile.get('full-path') break if not opf_path: raise Exception("Could not find OPF file in EPUB") # Parse OPF file opf_content = zip_file.read(opf_path).decode('utf-8') opf_root = ET.fromstring(opf_content) # Extract metadata title = "Unknown Title" author = "Unknown Author" metadata = opf_root.find('.//{http://www.idpf.org/2007/opf}metadata') if metadata is not None: # Extract title title_elem = metadata.find('.//{http://purl.org/dc/elements/1.1/}title') if title_elem is not None and title_elem.text: title = title_elem.text.strip() # Extract author creator_elem = metadata.find('.//{http://purl.org/dc/elements/1.1/}creator') if creator_elem is not None and creator_elem.text: author = creator_elem.text.strip() # Get spine order spine_items = [] spine = opf_root.find('.//{http://www.idpf.org/2007/opf}spine') if spine is not None: for itemref in spine.findall('.//{http://www.idpf.org/2007/opf}itemref'): spine_items.append(itemref.get('idref')) # Get manifest items manifest_items = {} manifest = opf_root.find('.//{http://www.idpf.org/2007/opf}manifest') if manifest is not None: for item in manifest.findall('.//{http://www.idpf.org/2007/opf}item'): item_id = item.get('id') href = item.get('href') media_type = item.get('media-type') manifest_items[item_id] = {'href': href, 'media-type': media_type} # Extract text content full_text = "" opf_dir = os.path.dirname(opf_path) for item_id in spine_items: if item_id in manifest_items: item_href = manifest_items[item_id]['href'] if opf_dir: file_path = f"{opf_dir}/{item_href}" else: file_path = item_href if file_path in zip_file.namelist(): try: content = zip_file.read(file_path).decode('utf-8') # Convert HTML to text h = html2text.HTML2Text() h.ignore_links = True h.ignore_images = True text = h.handle(content) full_text += text + "\n\n" except Exception as e: print(f"Error reading {file_path}: {e}") continue # Try to extract cover cover_path = EPUBParser._find_cover_image(zip_file, opf_root, opf_dir) return full_text.strip(), cover_path, title, author except Exception as e: raise Exception(f"EPUB parsing failed: {e}") @staticmethod def _find_cover_image(zip_file, opf_root, opf_dir) -> Optional[str]: """Find and extract cover image from EPUB""" try: # Look for cover in metadata metadata = opf_root.find('.//{http://www.idpf.org/2007/opf}metadata') if metadata is not None: for meta in metadata.findall('.//{http://www.idpf.org/2007/opf}meta'): if meta.get('name') == 'cover': cover_id = meta.get('content') break else: cover_id = None if cover_id: # Find cover in manifest manifest = opf_root.find('.//{http://www.idpf.org/2007/opf}manifest') if manifest is not None: for item in manifest.findall('.//{http://www.idpf.org/2007/opf}item'): if item.get('id') == cover_id: cover_href = item.get('href') if opf_dir: cover_path = f"{opf_dir}/{cover_href}" else: cover_path = cover_href if cover_path in zip_file.namelist(): # Extract cover to temp file cover_data = zip_file.read(cover_path) temp_cover = tempfile.NamedTemporaryFile( delete=False, suffix=os.path.splitext(cover_path)[1] ) temp_cover.write(cover_data) temp_cover.close() return temp_cover.name # Fallback: look for common cover image names common_cover_names = [ 'cover.jpg', 'cover.jpeg', 'cover.png', 'Cover.jpg', 'Cover.jpeg', 'Cover.png', 'OEBPS/cover.jpg', 'OEBPS/Cover.jpg', 'Images/cover.jpg', 'images/cover.jpg' ] for name in common_cover_names: if name in zip_file.namelist(): cover_data = zip_file.read(name) temp_cover = tempfile.NamedTemporaryFile( delete=False, suffix=os.path.splitext(name)[1] ) temp_cover.write(cover_data) temp_cover.close() return temp_cover.name except Exception as e: print(f"Error extracting cover: {e}") return None class CalibreConverter: """Handles ebook conversion using Calibre with improved error handling""" @staticmethod def is_calibre_available() -> bool: """Check if Calibre is installed""" try: result = subprocess.run(['ebook-convert', '--version'], capture_output=True, text=True, check=True) return True except (subprocess.CalledProcessError, FileNotFoundError): return False @staticmethod def get_supported_formats() -> List[str]: """Get list of ebook formats supported by Calibre""" return ['.epub', '.mobi', '.azw', '.azw3', '.fb2', '.lit', '.lrf', '.pdb', '.pdf', '.txt', '.rtf', '.odt', '.docx', '.html'] @staticmethod def extract_metadata(input_file: str) -> Tuple[str, str]: """Extract title and author from ebook""" try: result = subprocess.run([ 'ebook-meta', input_file ], capture_output=True, text=True, check=True) title = "Unknown Title" author = "Unknown Author" for line in result.stdout.split('\n'): if line.startswith('Title'): title = line.split(':', 1)[1].strip() elif line.startswith('Author(s)'): author = line.split(':', 1)[1].strip() return title, author except Exception as e: print(f"Could not extract metadata: {e}") return "Unknown Title", "Unknown Author" @staticmethod def extract_cover(input_file: str, output_dir: str) -> Optional[str]: """Extract cover image from ebook""" try: cover_path = os.path.join(output_dir, "cover.jpg") # Try ebook-meta first result = subprocess.run([ 'ebook-meta', input_file, '--get-cover', cover_path ], capture_output=True, text=True) if result.returncode == 0 and os.path.exists(cover_path): return cover_path # If that fails, try alternative method for EPUB if input_file.lower().endswith('.epub'): try: _, epub_cover, _, _ = EPUBParser.extract_text_and_metadata(input_file) if epub_cover: shutil.copy2(epub_cover, cover_path) os.unlink(epub_cover) # Clean up temp file return cover_path except: pass except Exception as e: print(f"Could not extract cover: {e}") return None @staticmethod def convert_to_text(input_file: str, output_dir: str) -> Tuple[str, Optional[str], str, str]: """Convert ebook to text and extract cover with metadata""" txt_path = os.path.join(output_dir, "converted_text.txt") cover_path = None title = "Unknown Title" author = "Unknown Author" # Try Calibre first if CalibreConverter.is_calibre_available(): try: # Extract metadata first title, author = CalibreConverter.extract_metadata(input_file) # Try different conversion parameters conversion_attempts = [ # Standard conversion ['ebook-convert', input_file, txt_path], # With additional options ['ebook-convert', input_file, txt_path, '--no-default-epub-cover', '--max-line-length=0'], # More permissive conversion ['ebook-convert', input_file, txt_path, '--no-default-epub-cover', '--max-line-length=0', '--disable-markup-chapter-headings', '--linearize-tables'], ] conversion_success = False last_error = None for cmd in conversion_attempts: try: print(f"Trying conversion with: {' '.join(cmd)}") result = subprocess.run( cmd, capture_output=True, text=True, check=True, timeout=300 # 5 minute timeout ) conversion_success = True break except subprocess.TimeoutExpired: last_error = "Conversion timed out" continue except subprocess.CalledProcessError as e: last_error = f"Command failed with exit code {e.returncode}: {e.stderr}" print(f"Conversion attempt failed: {last_error}") continue if conversion_success and os.path.exists(txt_path): # Extract cover cover_path = CalibreConverter.extract_cover(input_file, output_dir) return txt_path, cover_path, title, author else: print(f"All Calibre conversion attempts failed. Last error: {last_error}") except Exception as e: print(f"Calibre conversion error: {e}") # Fallback methods for different formats file_ext = os.path.splitext(input_file)[1].lower() if file_ext == '.epub': try: print("Falling back to direct EPUB parsing...") text_content, cover_path, title, author = EPUBParser.extract_text_and_metadata(input_file) with open(txt_path, 'w', encoding='utf-8') as f: f.write(text_content) return txt_path, cover_path, title, author except Exception as e: raise Exception(f"EPUB fallback parsing failed: {e}") elif file_ext == '.txt': # Just copy the text file shutil.copy2(input_file, txt_path) # Try to guess title from filename title = os.path.splitext(os.path.basename(input_file))[0].replace('_', ' ').replace('-', ' ').title() return txt_path, None, title, author elif file_ext == '.html' or file_ext == '.htm': try: print("Converting HTML to text...") with open(input_file, 'r', encoding='utf-8') as f: html_content = f.read() h = html2text.HTML2Text() h.ignore_links = True h.ignore_images = True text_content = h.handle(html_content) with open(txt_path, 'w', encoding='utf-8') as f: f.write(text_content) # Try to extract title from HTML title_match = re.search(r'(.*?)', html_content, re.IGNORECASE) if title_match: title = title_match.group(1).strip() return txt_path, None, title, author except Exception as e: raise Exception(f"HTML conversion failed: {e}") else: raise Exception(f"Unsupported format: {file_ext}. Calibre is required for this format but conversion failed.") class TextProcessor: """Handles text processing and chapter splitting""" @staticmethod def read_text_file(file_path: str) -> str: """Read text file with encoding detection""" encodings = ['utf-8', 'utf-16', 'latin-1', 'cp1252', 'iso-8859-1'] for encoding in encodings: try: with open(file_path, 'r', encoding=encoding) as f: content = f.read() # Validate that we got reasonable text if len(content.strip()) > 0: return content except (UnicodeDecodeError, UnicodeError): continue raise Exception("Could not decode text file with supported encodings") @staticmethod def split_into_chapters(text: str) -> List[Tuple[str, str]]: """Split text into chapters with improved detection""" # Enhanced chapter patterns chapter_patterns = [ r'^Chapter\s+\d+.*$', r'^CHAPTER\s+\d+.*$', r'^Chapter\s+[IVXLCDM]+.*$', # Roman numerals r'^CHAPTER\s+[IVXLCDM]+.*$', r'^\d+\.\s+.*$', r'^Part\s+\d+.*$', r'^PART\s+\d+.*$', r'^Book\s+\d+.*$', r'^BOOK\s+\d+.*$', r'^\*\*\*.*\*\*\*$', # Markdown-style separators r'^---.*---$', r'^###\s+.*$', # Markdown headers r'^##\s+.*$', r'^#\s+.*$', ] chapters = [] current_chapter = "" current_title = "Introduction" lines = text.split('\n') for line in lines: line_stripped = line.strip() if not line_stripped: current_chapter += '\n' continue # Check if this line is a chapter heading is_chapter = False for pattern in chapter_patterns: if re.match(pattern, line_stripped, re.IGNORECASE): is_chapter = True break # Also check for lines that are short and standalone (potential titles) if not is_chapter and len(line_stripped) < 100 and len(line_stripped) > 5: # Check if next few lines are empty or start content words = line_stripped.split() if len(words) <= 10 and any(word.lower() in ['chapter', 'part', 'book', 'prologue', 'epilogue'] for word in words): is_chapter = True if is_chapter and current_chapter.strip(): # Save previous chapter chapters.append((current_title, current_chapter.strip())) current_chapter = "" current_title = line_stripped else: current_chapter += line + '\n' # Add final chapter if current_chapter.strip(): chapters.append((current_title, current_chapter.strip())) # If no chapters found or only one very large chapter, treat as single book if len(chapters) <= 1: return [("Complete Book", text)] return chapters @staticmethod def clean_text_for_speech(text: str) -> str: """Clean text for better speech synthesis""" # Remove excessive whitespace text = re.sub(r'\s+', ' ', text) # Handle common abbreviations abbreviations = { 'Mr.': 'Mister', 'Mrs.': 'Missus', 'Ms.': 'Miss', 'Dr.': 'Doctor', 'Prof.': 'Professor', 'etc.': 'etcetera', 'vs.': 'versus', 'e.g.': 'for example', 'i.e.': 'that is', 'Inc.': 'Incorporated', 'Ltd.': 'Limited', 'Co.': 'Company', } for abbr, full in abbreviations.items(): text = text.replace(abbr, full) # Handle numbers and dates text = re.sub(r'\b(\d{1,2})/(\d{1,2})/(\d{4})\b', r'\1 slash \2 slash \3', text) text = re.sub(r'\b(\d+)\.(\d+)\b', r'\1 point \2', text) # Add pauses for punctuation text = text.replace('.', '. ') text = text.replace(',', ', ') text = text.replace(';', '; ') text = text.replace(':', ': ') text = text.replace('!', '! ') text = text.replace('?', '? ') # Remove markdown formatting text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) # Bold text = re.sub(r'\*(.*?)\*', r'\1', text) # Italic text = re.sub(r'`(.*?)`', r'\1', text) # Code text = re.sub(r'#{1,6}\s*(.*)', r'\1', text) # Headers # Remove URLs text = re.sub(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', '', text) # Remove multiple spaces text = re.sub(r'\s+', ' ', text) return text.strip() class AudioMetadataManager: """Handles adding metadata and cover art to audio files""" @staticmethod def add_metadata(audio_file: str, title: str, author: str, cover_file: Optional[str] = None): """Add metadata and cover art to audio file using mutagen""" try: file_ext = os.path.splitext(audio_file)[1].lower() if file_ext == '.mp3': AudioMetadataManager._add_mp3_metadata(audio_file, title, author, cover_file) elif file_ext == '.m4a': AudioMetadataManager._add_m4a_metadata(audio_file, title, author, cover_file) elif file_ext == '.flac': AudioMetadataManager._add_flac_metadata(audio_file, title, author, cover_file) elif file_ext == '.ogg': AudioMetadataManager._add_ogg_metadata(audio_file, title, author, cover_file) # WAV doesn't support metadata except Exception as e: print(f"Error adding metadata to {audio_file}: {e}") @staticmethod def _add_mp3_metadata(audio_file: str, title: str, author: str, cover_file: Optional[str]): """Add metadata to MP3 file""" try: audio = ID3(audio_file) audio.add(TIT2(encoding=3, text=title)) audio.add(TPE1(encoding=3, text=author)) audio.add(TALB(encoding=3, text="Audiobook")) if cover_file and os.path.exists(cover_file): with open(cover_file, 'rb') as f: cover_data = f.read() # Determine MIME type mime_type = 'image/jpeg' if cover_file.lower().endswith('.png'): mime_type = 'image/png' audio.add(APIC( encoding=3, mime=mime_type, type=3, # Cover (front) desc='Cover', data=cover_data )) audio.save() except Exception as e: print(f"Error adding MP3 metadata: {e}") @staticmethod def _add_m4a_metadata(audio_file: str, title: str, author: str, cover_file: Optional[str]): """Add metadata to M4A file""" try: audio = MP4(audio_file) audio['\xa9nam'] = title audio['\xa9ART'] = author audio['\xa9alb'] = "Audiobook" if cover_file and os.path.exists(cover_file): with open(cover_file, 'rb') as f: cover_data = f.read() if cover_file.lower().endswith('.png'): audio['covr'] = [MP4Cover(cover_data, MP4Cover.FORMAT_PNG)] else: audio['covr'] = [MP4Cover(cover_data, MP4Cover.FORMAT_JPEG)] audio.save() except Exception as e: print(f"Error adding M4A metadata: {e}") @staticmethod def _add_flac_metadata(audio_file: str, title: str, author: str, cover_file: Optional[str]): """Add metadata to FLAC file""" try: audio = FLAC(audio_file) audio['TITLE'] = title audio['ARTIST'] = author audio['ALBUM'] = "Audiobook" if cover_file and os.path.exists(cover_file): # FLAC cover art is more complex, skip for now pass audio.save() except Exception as e: print(f"Error adding FLAC metadata: {e}") @staticmethod def _add_ogg_metadata(audio_file: str, title: str, author: str, cover_file: Optional[str]): """Add metadata to OGG file""" try: audio = OggVorbis(audio_file) audio['TITLE'] = title audio['ARTIST'] = author audio['ALBUM'] = "Audiobook" # OGG cover art is complex, skip for now audio.save() except Exception as e: print(f"Error adding OGG metadata: {e}") class AudiobookGenerator: """Main audiobook generation class with improved error handling""" def __init__(self): self.temp_dir = None self.config = None def generate_speech(self, text: str, output_file: str, voice: str, speed: int, pitch: int, volume: int) -> bool: """Generate speech from text using espeak-ng with error handling""" try: # Split very long text into smaller chunks max_chunk_size = 5000 # characters if len(text) > max_chunk_size: return self._generate_speech_chunked(text, output_file, voice, speed, pitch, volume) cmd = [ 'espeak-ng', '-v', voice, '-s', str(speed), '-p', str(pitch), '-a', str(volume), '-w', output_file, text ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) if result.returncode != 0: print(f"Espeak error: {result.stderr}") return False return os.path.exists(output_file) and os.path.getsize(output_file) > 0 except subprocess.TimeoutExpired: print("Speech generation timed out") return False except Exception as e: print(f"Error generating speech: {e}") return False def _generate_speech_chunked(self, text: str, output_file: str, voice: str, speed: int, pitch: int, volume: int) -> bool: """Generate speech for long text by splitting into chunks""" try: # Split text into sentences sentences = re.split(r'[.!?]+', text) chunks = [] current_chunk = "" for sentence in sentences: sentence = sentence.strip() if not sentence: continue if len(current_chunk) + len(sentence) > 4000: # Leave room for punctuation if current_chunk: chunks.append(current_chunk.strip() + '.') current_chunk = sentence else: if current_chunk: current_chunk += '. ' + sentence else: current_chunk = sentence if current_chunk: chunks.append(current_chunk.strip() + '.') # Generate audio for each chunk temp_files = [] for i, chunk in enumerate(chunks): temp_file = f"{output_file}_chunk_{i}.wav" cmd = [ 'espeak-ng', '-v', voice, '-s', str(speed), '-p', str(pitch), '-a', str(volume), '-w', temp_file, chunk ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode == 0 and os.path.exists(temp_file): temp_files.append(temp_file) else: print(f"Failed to generate chunk {i}: {result.stderr}") # Combine chunks if temp_files: self._combine_wav_files(temp_files, output_file) # Clean up temp files for temp_file in temp_files: try: os.unlink(temp_file) except: pass return os.path.exists(output_file) return False except Exception as e: print(f"Error in chunked speech generation: {e}") return False def _combine_wav_files(self, wav_files: List[str], output_file: str): """Combine multiple WAV files into one""" try: combined = AudioSegment.empty() for wav_file in wav_files: if os.path.exists(wav_file): audio = AudioSegment.from_wav(wav_file) combined += audio # Add small pause between chunks combined += AudioSegment.silent(duration=200) # 200ms combined.export(output_file, format="wav") except Exception as e: print(f"Error combining WAV files: {e}") # Fallback: just copy the first file if wav_files and os.path.exists(wav_files[0]): shutil.copy2(wav_files[0], output_file) def convert_audio_format(self, input_file: str, output_file: str, format_type: str) -> bool: """Convert audio to different formats""" try: audio = AudioSegment.from_wav(input_file) if format_type.lower() == 'mp3': audio.export(output_file, format='mp3', bitrate='128k') elif format_type.lower() == 'ogg': audio.export(output_file, format='ogg') elif format_type.lower() == 'flac': audio.export(output_file, format='flac') elif format_type.lower() == 'm4a': audio.export(output_file, format='mp4', codec='aac') else: # wav shutil.copy2(input_file, output_file) return True except Exception as e: print(f"Error converting audio format: {e}") return False def generate_audiobook(self, config: AudiobookConfig, progress_callback=None) -> Dict: """Main audiobook generation function with improved error handling""" global current_progress, stop_generation self.config = config results = {"success": False, "files": [], "error": None, "metadata": {}} try: # Create temporary directory self.temp_dir = tempfile.mkdtemp() # Update progress current_progress.update({"status": "Processing input file...", "value": 5}) if progress_callback: progress_callback(current_progress) # Convert ebook to text if needed text_content = "" cover_file = None title = config.book_title author = config.author file_ext = os.path.splitext(config.input_file)[1].lower() if file_ext == '.txt': text_content = TextProcessor.read_text_file(config.input_file) # Try to guess title from filename title = os.path.splitext(os.path.basename(config.input_file))[0].replace('_', ' ').replace('-', ' ').title() else: try: text_file, cover_file, title, author = CalibreConverter.convert_to_text( config.input_file, self.temp_dir ) text_content = TextProcessor.read_text_file(text_file) except Exception as e: raise Exception(f"Failed to convert ebook: {str(e)}") # Store metadata results["metadata"] = { "title": title, "author": author, "cover_file": cover_file } if stop_generation: return {"success": False, "error": "Generation stopped by user"} # Validate text content if not text_content or len(text_content.strip()) < 10: raise Exception("No readable text content found in the file") # Split into chapters if requested current_progress.update({"status": "Processing text...", "value": 15}) if progress_callback: progress_callback(current_progress) if config.chapter_split: chapters = TextProcessor.split_into_chapters(text_content) else: chapters = [("Complete Audiobook", text_content)] current_progress.update({"total": len(chapters)}) # Parse voice selection voice_name, lang_code = EspeakVoiceManager.parse_voice_selection(config.voice) # Generate audio for each chapter audio_files = [] for i, (chapter_title, chapter_text) in enumerate(chapters): if stop_generation: return {"success": False, "error": "Generation stopped by user"} current_progress.update({ "status": f"Generating audio for: {chapter_title[:50]}...", "value": 20 + (i * 60 // len(chapters)) }) if progress_callback: progress_callback(current_progress) # Clean text for speech clean_text = TextProcessor.clean_text_for_speech(chapter_text) if not clean_text.strip() or len(clean_text.strip()) < 5: print(f"Skipping empty chapter: {chapter_title}") continue # Generate speech wav_file = os.path.join(self.temp_dir, f"chapter_{i:03d}.wav") success = self.generate_speech( clean_text, wav_file, voice_name, config.speed, config.pitch, config.volume ) if not success: print(f"Failed to generate audio for chapter: {chapter_title}") continue audio_files.append({ "wav_file": wav_file, "title": chapter_title, "index": i }) if stop_generation: return {"success": False, "error": "Generation stopped by user"} if not audio_files: raise Exception("No audio files were successfully generated") # Create final audiobook file current_progress.update({"status": "Creating final audiobook...", "value": 85}) if progress_callback: progress_callback(current_progress) # Always create a single combined file safe_title = self._sanitize_filename(title) final_filename = f"{safe_title}.{config.output_format}" final_file = os.path.join(config.output_dir, final_filename) # Combine all audio files self._combine_audio_files([f["wav_file"] for f in audio_files], final_file, config.output_format) if not os.path.exists(final_file): raise Exception("Failed to create final audiobook file") # Add metadata and cover art if config.include_cover: AudioMetadataManager.add_metadata(final_file, title, author, cover_file) current_progress.update({"status": "Complete!", "value": 100}) if progress_callback: progress_callback(current_progress) results["success"] = True results["files"] = [{ "file": final_file, "title": title, "size": os.path.getsize(final_file) }] # Also create chapter files if requested if config.chapter_split and len(chapters) > 1: chapter_files = [] for audio_info in audio_files: safe_chapter_title = self._sanitize_filename(audio_info["title"]) chapter_filename = f"{audio_info['index']:03d}_{safe_chapter_title}.{config.output_format}" chapter_file = os.path.join(config.output_dir, chapter_filename) if self.convert_audio_format(audio_info["wav_file"], chapter_file, config.output_format): if config.include_cover: AudioMetadataManager.add_metadata(chapter_file, audio_info["title"], author, cover_file) chapter_files.append({ "file": chapter_file, "title": audio_info["title"], "size": os.path.getsize(chapter_file) }) results["files"].extend(chapter_files) except Exception as e: error_msg = str(e) results["error"] = error_msg current_progress.update({"status": f"Error: {error_msg}", "value": 0}) if progress_callback: progress_callback(current_progress) finally: # Cleanup temp directory if self.temp_dir and os.path.exists(self.temp_dir): try: shutil.rmtree(self.temp_dir) except: pass return results def _sanitize_filename(self, filename: str) -> str: """Sanitize filename for filesystem compatibility""" # Remove invalid characters sanitized = re.sub(r'[<>:"/\\|?*]', '', filename) # Replace spaces with underscores sanitized = re.sub(r'\s+', '_', sanitized) # Remove extra underscores sanitized = re.sub(r'_+', '_', sanitized) # Limit length return sanitized[:50].strip('_') def _combine_audio_files(self, audio_files: List[str], output_file: str, format_type: str): """Combine multiple audio files into one""" try: combined = AudioSegment.empty() for audio_file in audio_files: if os.path.exists(audio_file): audio = AudioSegment.from_wav(audio_file) combined += audio # Add small pause between chapters combined += AudioSegment.silent(duration=1000) # 1 second # Export combined audio if format_type.lower() == 'mp3': combined.export(output_file, format='mp3', bitrate='128k') elif format_type.lower() == 'ogg': combined.export(output_file, format='ogg') elif format_type.lower() == 'flac': combined.export(output_file, format='flac') elif format_type.lower() == 'm4a': combined.export(output_file, format='mp4', codec='aac') else: combined.export(output_file, format='wav') except Exception as e: print(f"Error combining audio files: {e}") raise # Gradio Interface Functions def check_dependencies(): """Check if required dependencies are available""" deps = { "espeak-ng": False, "calibre": False, "ffmpeg": False } try: subprocess.run(['espeak-ng', '--version'], capture_output=True, check=True) deps["espeak-ng"] = True except: pass try: subprocess.run(['ebook-convert', '--version'], capture_output=True, check=True) deps["calibre"] = True except: pass try: subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True) deps["ffmpeg"] = True except: pass return deps def get_dependency_status(): """Get formatted dependency status""" deps = check_dependencies() status = [] for dep, available in deps.items(): icon = "✅" if available else "❌" status.append(f"{icon} {dep}") return " | ".join(status) def generate_audiobook_wrapper(input_file, voice, speed, pitch, volume, output_format, chapter_split, include_cover, progress=gr.Progress()): """Wrapper function for Gradio interface""" global current_progress, stop_generation, generation_thread if not input_file: return "❌ Please select an input file", "", [], None, None # Reset stop flag stop_generation = False # Create output directory output_dir = os.path.join(os.getcwd(), "audiobook_output") os.makedirs(output_dir, exist_ok=True) # Create config config = AudiobookConfig( input_file=input_file.name, output_dir=output_dir, voice=voice, speed=speed, pitch=pitch, volume=volume, output_format=output_format, chapter_split=chapter_split, include_cover=include_cover, language="auto" ) # Generate audiobook generator = AudiobookGenerator() def progress_callback(prog_data): if prog_data["total"] > 0: progress_val = prog_data["value"] / 100.0 progress(progress_val, desc=prog_data["status"]) # Run generation in current thread (Gradio handles threading) results = generator.generate_audiobook(config, progress_callback) if results["success"]: file_list = [] total_size = 0 for file_info in results["files"]: file_list.append(file_info["file"]) total_size += file_info["size"] size_mb = total_size / (1024 * 1024) status_msg = f"✅ Generated {len(file_list)} audio file(s) ({size_mb:.1f} MB total)" # Get metadata for display metadata = results.get("metadata", {}) title = metadata.get("title", "Unknown Title") author = metadata.get("author", "Unknown Author") cover_file = metadata.get("cover_file") metadata_display = f"**Title:** {title}\n**Author:** {author}" return status_msg, f"Output directory: {output_dir}", file_list, metadata_display, cover_file else: error_msg = f"❌ Generation failed: {results.get('error', 'Unknown error')}" return error_msg, "", [], "", None def stop_generation_wrapper(): """Stop the current generation process""" global stop_generation stop_generation = True return "⏹️ Stopping generation..." def get_file_info(file_path): """Get information about uploaded file with cover preview""" if not file_path: return "No file selected", None, "" try: file_size = os.path.getsize(file_path.name) size_mb = file_size / (1024 * 1024) file_ext = os.path.splitext(file_path.name)[1].lower() supported_formats = CalibreConverter.get_supported_formats() is_supported = file_ext in supported_formats or file_ext == '.txt' support_status = "✅ Supported" if is_supported else "❌ Not supported" deps = check_dependencies() if file_ext != '.txt' and not deps["calibre"] and file_ext == '.epub': support_status += " (Direct EPUB parsing available)" elif file_ext != '.txt' and not deps["calibre"]: support_status = "❌ Requires Calibre" file_info_text = f"File: {os.path.basename(file_path.name)}\nSize: {size_mb:.1f} MB\nFormat: {file_ext}\nStatus: {support_status}" # Try to extract cover and metadata for preview cover_image = None metadata_text = "" if file_ext == '.epub': try: temp_dir = tempfile.mkdtemp() try: if deps["calibre"]: title, author = CalibreConverter.extract_metadata(file_path.name) cover_path = CalibreConverter.extract_cover(file_path.name, temp_dir) else: # Use direct EPUB parsing _, cover_path, title, author = EPUBParser.extract_text_and_metadata(file_path.name) metadata_text = f"**Title:** {title}\n**Author:** {author}" if cover_path and os.path.exists(cover_path): cover_image = cover_path except Exception as e: print(f"Error extracting metadata: {e}") finally: # Don't clean up temp_dir yet if we have a cover image if not cover_image: try: shutil.rmtree(temp_dir) except: pass except Exception as e: print(f"Error reading EPUB: {e}") return file_info_text, cover_image, metadata_text except Exception as e: return f"Error reading file: {e}", None, "" # Create Gradio Interface def create_interface(): """Create the Gradio web interface""" # Get available voices voices = EspeakVoiceManager.get_voice_list() if not voices: voices = ["English (en) [U]"] with gr.Blocks( title="Audiobook Generator - Complete", theme=gr.themes.Soft(), css=""" .dependency-status { font-family: monospace; font-size: 14px; } .file-info { background: #f0f0f0; padding: 10px; border-radius: 5px; } .cover-image { max-width: 200px; max-height: 300px; object-fit: contain; } .metadata-display { background: #e8f4f8; padding: 10px; border-radius: 5px; } """ ) as interface: gr.Markdown(""" # 🎧 Audiobook Generator - Complete Version Convert ebooks and text files to audiobooks using espeak-ng text-to-speech. Supports multiple languages, formats, and includes cover art embedding. **Features:** - Single combined audiobook file output (default) - Optional chapter splitting - Cover art preview and embedding - Metadata extraction and display - Multiple output formats with proper metadata """) # Dependency status with gr.Row(): dependency_status = gr.Markdown( f"**Dependencies:** {get_dependency_status()}", elem_classes=["dependency-status"] ) with gr.Row(): with gr.Column(scale=1): gr.Markdown("### Input Settings") input_file = gr.File( label="Select Ebook or Text File", file_types=[".txt", ".epub", ".mobi", ".azw", ".azw3", ".pdf", ".fb2", ".lit", ".pdb", ".rtf", ".odt", ".docx", ".html"], type="filepath" ) with gr.Row(): with gr.Column(scale=2): file_info = gr.Markdown("", elem_classes=["file-info"]) book_metadata = gr.Markdown("", elem_classes=["metadata-display"]) with gr.Column(scale=1): cover_preview = gr.Image( label="Cover Art Preview", show_label=True, elem_classes=["cover-image"], interactive=False ) gr.Markdown("### Voice Settings") voice = gr.Dropdown( choices=voices, value=voices[0] if voices else "English (en) [U]", label="Voice/Language", info="Select the voice and language for speech synthesis" ) with gr.Row(): speed = gr.Slider( minimum=80, maximum=300, value=175, label="Speed (words per minute)", info="Speaking speed" ) pitch = gr.Slider( minimum=0, maximum=99, value=50, label="Pitch", info="Voice pitch (0=low, 99=high)" ) volume = gr.Slider( minimum=0, maximum=200, value=100, label="Volume", info="Audio volume level" ) gr.Markdown("### Output Settings") output_format = gr.Dropdown( choices=["mp3", "wav", "ogg", "flac", "m4a"], value="mp3", label="Output Format", info="Audio file format" ) with gr.Row(): chapter_split = gr.Checkbox( label="Also Create Chapter Files", value=False, info="Create individual chapter files in addition to the main audiobook" ) include_cover = gr.Checkbox( label="Include Cover Art", value=True, info="Embed cover art in audio files" ) with gr.Column(scale=1): gr.Markdown("### Generation") generate_btn = gr.Button( "🎵 Generate Audiobook", variant="primary", size="lg" ) stop_btn = gr.Button( "⏹️ Stop Generation", variant="secondary" ) status_output = gr.Markdown("") output_info = gr.Markdown("") gr.Markdown("### Book Information") final_metadata = gr.Markdown("", elem_classes=["metadata-display"]) final_cover = gr.Image( label="Final Cover Art", show_label=True, elem_classes=["cover-image"], interactive=False ) gr.Markdown("### Generated Files") output_files = gr.File( label="Download Generated Audiobook Files", file_count="multiple", type="filepath", interactive=False ) gr.Markdown(""" ### Output Behavior **Default:** Creates a single combined audiobook file with proper metadata and cover art. **Chapter Split Enabled:** Creates both the main audiobook file AND individual chapter files. ### Troubleshooting **If Calibre conversion fails:** - EPUB files will use direct parsing fallback - HTML files will be converted using html2text - Other formats require Calibre to be installed **For best results:** - Use text or EPUB files for most reliable conversion - Install Calibre for full format support - MP3 and M4A formats support cover art best ### Usage Instructions 1. **Upload a file**: Select an ebook or text file 2. **Preview**: View the extracted cover art and metadata 3. **Choose voice**: Select from available espeak-ng voices 4. **Adjust settings**: Configure speed, pitch, volume, and format 5. **Generate**: Click generate for a single audiobook file 6. **Download**: Get your complete audiobook with metadata ### Supported Formats - **Input**: TXT, EPUB, MOBI, AZW, AZW3, PDF, FB2, HTML, RTF, ODT, DOCX - **Output**: MP3, WAV, OGG, FLAC, M4A (with metadata support) """) # Event handlers input_file.change( fn=get_file_info, inputs=[input_file], outputs=[file_info, cover_preview, book_metadata] ) generate_btn.click( fn=generate_audiobook_wrapper, inputs=[ input_file, voice, speed, pitch, volume, output_format, chapter_split, include_cover ], outputs=[status_output, output_info, output_files, final_metadata, final_cover] ) stop_btn.click( fn=stop_generation_wrapper, inputs=[], outputs=[status_output] ) return interface if __name__ == "__main__": print("🎧 Starting Complete Audiobook Generator...") print(f"Dependencies: {get_dependency_status()}") # Install required Python packages if missing required_packages = ['html2text', 'pillow', 'mutagen'] for package in required_packages: try: if package == 'pillow': import PIL else: __import__(package.replace('-', '_')) except ImportError: print(f"Installing {package}...") subprocess.run([sys.executable, '-m', 'pip', 'install', package]) # Import after installation import html2text from PIL import Image import mutagen # Check critical dependencies deps = check_dependencies() if not deps["espeak-ng"]: print("❌ Error: espeak-ng is required but not found!") print("Please install espeak-ng:") print(" Ubuntu/Debian: sudo apt install espeak-ng") print(" macOS: brew install espeak-ng") print(" Windows: Download from https://github.com/espeak-ng/espeak-ng/releases") sys.exit(1) if not deps["calibre"]: print("⚠️ Warning: Calibre not found. EPUB files will use direct parsing.") print("Install Calibre from: https://calibre-ebook.com/download") if not deps["ffmpeg"]: print("⚠️ Warning: FFmpeg not found. Using mutagen for metadata instead.") try: interface = create_interface() interface.launch( server_name="127.0.0.1", server_port=7860, share=True, show_error=True, quiet=False ) except Exception as e: print(f"❌ Error starting interface: {e}") sys.exit(1)