Spaces:
Runtime error
Runtime error
Update app.py
Browse files
app.py
CHANGED
@@ -1,813 +1,233 @@
|
|
|
|
1 |
import os
|
2 |
-
import shutil
|
3 |
import subprocess
|
4 |
-
import re
|
5 |
-
from pydub import AudioSegment
|
6 |
import tempfile
|
7 |
-
from tqdm import tqdm
|
8 |
-
import gradio as gr
|
9 |
-
import nltk
|
10 |
-
import ebooklib
|
11 |
-
import bs4
|
12 |
-
from ebooklib import epub
|
13 |
-
from bs4 import BeautifulSoup
|
14 |
-
from nltk.tokenize import sent_tokenize
|
15 |
-
import csv
|
16 |
-
import argparse
|
17 |
import threading
|
18 |
-
import logging
|
19 |
-
from datetime import datetime
|
20 |
import time
|
21 |
-
import
|
22 |
-
|
23 |
-
# Setup logging
|
24 |
-
logging.basicConfig(
|
25 |
-
level=logging.INFO,
|
26 |
-
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
27 |
-
handlers=[
|
28 |
-
logging.FileHandler("audiobook_converter.log"),
|
29 |
-
logging.StreamHandler()
|
30 |
-
]
|
31 |
-
)
|
32 |
-
logger = logging.getLogger("audiobook_converter")
|
33 |
-
|
34 |
-
# Download NLTK resources if not already present
|
35 |
-
try:
|
36 |
-
nltk.data.find('tokenizers/punkt')
|
37 |
-
except LookupError:
|
38 |
-
logger.info("Downloading NLTK punkt tokenizer...")
|
39 |
-
nltk.download('punkt', quiet=True)
|
40 |
-
|
41 |
-
# Utility functions for directory management
|
42 |
-
def ensure_directory(directory_path):
|
43 |
-
"""Create directory if it doesn't exist."""
|
44 |
-
if not os.path.exists(directory_path):
|
45 |
-
os.makedirs(directory_path)
|
46 |
-
logger.info(f"Created directory: {directory_path}")
|
47 |
-
return directory_path
|
48 |
-
|
49 |
-
def remove_directory(folder_path):
|
50 |
-
"""Remove directory and all its contents."""
|
51 |
-
if os.path.exists(folder_path):
|
52 |
-
try:
|
53 |
-
shutil.rmtree(folder_path)
|
54 |
-
logger.info(f"Removed directory: {folder_path}")
|
55 |
-
except Exception as e:
|
56 |
-
logger.error(f"Error removing directory {folder_path}: {e}")
|
57 |
-
|
58 |
-
def wipe_directory(folder_path):
|
59 |
-
"""Remove all contents of a directory without deleting the directory itself."""
|
60 |
-
if not os.path.exists(folder_path):
|
61 |
-
logger.warning(f"Directory does not exist: {folder_path}")
|
62 |
-
return
|
63 |
-
|
64 |
-
for item in os.listdir(folder_path):
|
65 |
-
item_path = os.path.join(folder_path, item)
|
66 |
-
try:
|
67 |
-
if os.path.isfile(item_path):
|
68 |
-
os.remove(item_path)
|
69 |
-
elif os.path.isdir(item_path):
|
70 |
-
shutil.rmtree(item_path)
|
71 |
-
except Exception as e:
|
72 |
-
logger.error(f"Error removing {item_path}: {e}")
|
73 |
-
|
74 |
-
logger.info(f"Wiped contents of directory: {folder_path}")
|
75 |
-
|
76 |
-
# Text processing functions
|
77 |
-
def clean_text(text):
|
78 |
-
"""Clean up text by removing unnecessary whitespace and fixing common issues."""
|
79 |
-
# Replace multiple newlines with a single one
|
80 |
-
text = re.sub(r'\n\s*\n', '\n\n', text)
|
81 |
-
# Replace multiple spaces with a single space
|
82 |
-
text = re.sub(r' +', ' ', text)
|
83 |
-
# Fix broken sentences (e.g., "word . Next" -> "word. Next")
|
84 |
-
text = re.sub(r'(\w) \. (\w)', r'\1. \2', text)
|
85 |
-
return text.strip()
|
86 |
-
|
87 |
-
def split_into_natural_sentences(text):
|
88 |
-
"""Split text into natural sentences using NLTK with additional rules."""
|
89 |
-
# Initial sentence splitting
|
90 |
-
sentences = sent_tokenize(text)
|
91 |
-
|
92 |
-
# Post-process sentences to handle special cases
|
93 |
-
processed_sentences = []
|
94 |
-
buffer = ""
|
95 |
-
|
96 |
-
for sent in sentences:
|
97 |
-
# Handle quotes that span multiple sentences but should be treated as one
|
98 |
-
if buffer:
|
99 |
-
current = buffer + " " + sent
|
100 |
-
buffer = ""
|
101 |
-
else:
|
102 |
-
current = sent
|
103 |
-
|
104 |
-
# Check for unbalanced quotes, which might indicate a continuing sentence
|
105 |
-
if current.count('"') % 2 != 0 or current.count("'") % 2 != 0:
|
106 |
-
buffer = current
|
107 |
-
continue
|
108 |
-
|
109 |
-
# Check if sentence ends with abbreviation or is too short (might be a continuation)
|
110 |
-
if len(current) < 20 and not re.search(r'[.!?]\s*$', current):
|
111 |
-
buffer = current
|
112 |
-
continue
|
113 |
-
|
114 |
-
processed_sentences.append(current)
|
115 |
-
|
116 |
-
# Add any remaining buffer
|
117 |
-
if buffer:
|
118 |
-
processed_sentences.append(buffer)
|
119 |
-
|
120 |
-
return processed_sentences
|
121 |
|
122 |
-
|
123 |
-
|
124 |
-
"""Extract metadata and cover image from an ebook."""
|
125 |
-
cover_path = ebook_path.rsplit('.', 1)[0] + '.jpg'
|
126 |
-
|
127 |
try:
|
128 |
-
subprocess.run(['
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
logger.warning("Cover extraction failed or resulted in empty file")
|
137 |
-
return None
|
138 |
except Exception as e:
|
139 |
-
|
140 |
-
return
|
141 |
-
|
142 |
-
def convert_to_epub(input_path, output_path):
|
143 |
-
"""Convert any ebook format to EPUB using Calibre."""
|
144 |
-
try:
|
145 |
-
logger.info(f"Converting {input_path} to EPUB format...")
|
146 |
-
result = subprocess.run(
|
147 |
-
['ebook-convert', input_path, output_path, '--enable-heuristics'],
|
148 |
-
check=True,
|
149 |
-
stderr=subprocess.PIPE,
|
150 |
-
stdout=subprocess.PIPE
|
151 |
-
)
|
152 |
-
logger.info(f"Successfully converted to EPUB: {output_path}")
|
153 |
-
return True
|
154 |
-
except subprocess.CalledProcessError as e:
|
155 |
-
logger.error(f"Error converting to EPUB: {e}")
|
156 |
-
logger.error(f"STDERR: {e.stderr.decode('utf-8', errors='replace')}")
|
157 |
-
return False
|
158 |
|
159 |
-
def
|
160 |
-
"""Convert
|
|
|
|
|
|
|
161 |
try:
|
162 |
-
|
163 |
-
|
164 |
-
['ebook-convert', input_path, output_path,
|
165 |
-
'--enable-heuristics',
|
166 |
-
'--chapter-mark=pagebreak',
|
167 |
-
'--paragraph-type=unformatted'],
|
168 |
check=True,
|
169 |
-
|
170 |
-
|
171 |
)
|
172 |
-
|
173 |
-
return True
|
174 |
except subprocess.CalledProcessError as e:
|
175 |
-
|
176 |
-
|
177 |
-
return False
|
178 |
-
|
179 |
-
def detect_chapters_from_text(text_path):
|
180 |
-
"""Detect chapters in a text file based on common patterns."""
|
181 |
-
with open(text_path, 'r', encoding='utf-8', errors='replace') as f:
|
182 |
-
content = f.read()
|
183 |
-
|
184 |
-
# Different chapter detection patterns
|
185 |
-
chapter_patterns = [
|
186 |
-
r'(?:^|\n)(?:\s*)(?:Chapter|CHAPTER)\s+[0-9IVXLCDM]+(?:\s*:|\.\s|\s)(.+?)(?=\n)',
|
187 |
-
r'(?:^|\n)(?:\s*)(?:Chapter|CHAPTER)\s+[0-9IVXLCDM]+(?:\s*:|\.\s|\s)',
|
188 |
-
r'(?:^|\n)(?:\s*)(?:[0-9]+|[IVXLCDM]+)\.?\s+(.+?)(?=\n)',
|
189 |
-
r'(?:^|\n)(?:\s*)\* \* \*(?:\s*\n)',
|
190 |
-
r'(?:^|\n)(?:\s*)[-—]\s*(\d+\s*[-—]|\w+)(?:\s*\n)'
|
191 |
-
]
|
192 |
-
|
193 |
-
chapters = []
|
194 |
-
|
195 |
-
for pattern in chapter_patterns:
|
196 |
-
matches = re.finditer(pattern, content, re.MULTILINE)
|
197 |
-
positions = [(m.start(), m.group()) for m in matches]
|
198 |
-
if positions:
|
199 |
-
# If we found chapters with this pattern, add to our list
|
200 |
-
for i, (pos, title) in enumerate(positions):
|
201 |
-
end_pos = positions[i+1][0] if i < len(positions)-1 else len(content)
|
202 |
-
chapter_text = content[pos:end_pos].strip()
|
203 |
-
chapters.append((i+1, clean_text(chapter_text)))
|
204 |
-
|
205 |
-
# If we found chapters with this pattern, stop looking
|
206 |
-
if len(chapters) > 3: # Require at least 3 chapters for a valid detection
|
207 |
-
break
|
208 |
-
|
209 |
-
# If no chapters detected, create artificial chapters based on length
|
210 |
-
if not chapters:
|
211 |
-
logger.info("No clear chapter markers found, creating artificial chapters")
|
212 |
-
chunk_size = min(10000, max(5000, len(content) // 20)) # Aim for ~20 chapters
|
213 |
-
|
214 |
-
# Split content into chunks
|
215 |
-
chunks = [content[i:i+chunk_size] for i in range(0, len(content), chunk_size)]
|
216 |
-
chapters = [(i+1, clean_text(chunk)) for i, chunk in enumerate(chunks)]
|
217 |
-
|
218 |
-
return chapters
|
219 |
-
|
220 |
-
def save_chapters_as_files(chapters, output_dir):
|
221 |
-
"""Save detected chapters as individual text files."""
|
222 |
-
ensure_directory(output_dir)
|
223 |
-
|
224 |
-
for chapter_num, chapter_text in chapters:
|
225 |
-
filename = f"chapter_{chapter_num:03d}.txt"
|
226 |
-
filepath = os.path.join(output_dir, filename)
|
227 |
-
|
228 |
-
with open(filepath, 'w', encoding='utf-8') as f:
|
229 |
-
f.write(chapter_text)
|
230 |
-
|
231 |
-
logger.info(f"Saved chapter {chapter_num} to {filename}")
|
232 |
-
|
233 |
-
return len(chapters)
|
234 |
-
|
235 |
-
def process_ebook_to_chapters(ebook_path, chapters_dir):
|
236 |
-
"""Process ebook into chapter text files."""
|
237 |
-
ensure_directory(chapters_dir)
|
238 |
-
|
239 |
-
# Create temp directory for intermediate files
|
240 |
-
temp_dir = os.path.join(os.path.dirname(chapters_dir), "temp")
|
241 |
-
ensure_directory(temp_dir)
|
242 |
-
|
243 |
-
# Determine file paths
|
244 |
-
temp_epub = os.path.join(temp_dir, "converted.epub")
|
245 |
-
temp_txt = os.path.join(temp_dir, "converted.txt")
|
246 |
-
|
247 |
-
# First try direct conversion to text
|
248 |
-
if convert_to_text(ebook_path, temp_txt):
|
249 |
-
chapters = detect_chapters_from_text(temp_txt)
|
250 |
-
num_chapters = save_chapters_as_files(chapters, chapters_dir)
|
251 |
-
logger.info(f"Processed {num_chapters} chapters from text conversion")
|
252 |
-
return num_chapters
|
253 |
-
|
254 |
-
# If that fails, try EPUB conversion first
|
255 |
-
logger.info("Direct text conversion failed, trying via EPUB...")
|
256 |
-
if convert_to_epub(ebook_path, temp_epub) and convert_to_text(temp_epub, temp_txt):
|
257 |
-
chapters = detect_chapters_from_text(temp_txt)
|
258 |
-
num_chapters = save_chapters_as_files(chapters, chapters_dir)
|
259 |
-
logger.info(f"Processed {num_chapters} chapters via EPUB conversion")
|
260 |
-
return num_chapters
|
261 |
-
|
262 |
-
# If both methods fail, return 0 chapters
|
263 |
-
logger.error("Failed to process ebook into chapters")
|
264 |
-
return 0
|
265 |
-
|
266 |
-
# Audio processing functions
|
267 |
-
def sanitize_for_espeak(text):
|
268 |
-
"""Sanitize text for espeak compatibility."""
|
269 |
-
# Replace problematic characters
|
270 |
-
text = re.sub(r'[–—]', '-', text) # Em/en dashes to hyphens
|
271 |
-
text = re.sub(r'["""]', '"', text) # Smart quotes to regular quotes
|
272 |
-
text = re.sub(r'[''`]', "'", text) # Smart apostrophes to regular apostrophes
|
273 |
-
text = re.sub(r'[…]', '...', text) # Ellipsis character to three dots
|
274 |
-
|
275 |
-
# Remove or replace other problematic characters
|
276 |
-
text = re.sub(r'[<>|]', ' ', text)
|
277 |
-
text = re.sub(r'[\x00-\x1F\x7F]', '', text) # Control characters
|
278 |
-
|
279 |
-
return text
|
280 |
|
281 |
-
def
|
282 |
-
"""
|
283 |
try:
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
temp_file_path = temp_file.name
|
288 |
-
|
289 |
-
# Call espeak-ng with the text file
|
290 |
-
subprocess.run([
|
291 |
-
"espeak-ng",
|
292 |
-
"-v", voice,
|
293 |
-
"-f", temp_file_path,
|
294 |
-
"-w", output_path,
|
295 |
-
f"-s{speed}",
|
296 |
-
f"-p{pitch}",
|
297 |
-
f"-g{gap}"
|
298 |
-
], check=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
|
299 |
-
|
300 |
-
# Remove the temporary file
|
301 |
-
os.unlink(temp_file_path)
|
302 |
-
|
303 |
-
# Verify the output file exists and has content
|
304 |
-
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
|
305 |
-
return True
|
306 |
-
else:
|
307 |
-
logger.error(f"Speech synthesis produced empty file: {output_path}")
|
308 |
-
return False
|
309 |
-
|
310 |
-
except subprocess.CalledProcessError as e:
|
311 |
-
logger.error(f"Error in speech synthesis: {e}")
|
312 |
-
logger.error(f"STDERR: {e.stderr.decode('utf-8', errors='replace')}")
|
313 |
-
return False
|
314 |
except Exception as e:
|
315 |
-
|
316 |
-
return
|
317 |
|
318 |
-
def
|
319 |
-
"""Convert
|
320 |
-
|
321 |
-
|
322 |
-
# Get all chapter files
|
323 |
-
chapter_files = [f for f in os.listdir(chapters_dir) if f.startswith('chapter_') and f.endswith('.txt')]
|
324 |
-
chapter_files.sort(key=lambda f: int(re.search(r'chapter_(\d+)', f).group(1)))
|
325 |
-
|
326 |
-
total_chapters = len(chapter_files)
|
327 |
-
processed_chapters = 0
|
328 |
-
failed_chapters = 0
|
329 |
-
|
330 |
-
# Create a tqdm progress bar for terminal output
|
331 |
-
pbar = tqdm(total=total_chapters, desc="Converting chapters to audio")
|
332 |
-
|
333 |
-
for chapter_file in chapter_files:
|
334 |
-
chapter_path = os.path.join(chapters_dir, chapter_file)
|
335 |
-
chapter_num = int(re.search(r'chapter_(\d+)', chapter_file).group(1))
|
336 |
-
output_file = os.path.join(output_audio_dir, f"audio_chapter_{chapter_num:03d}.wav")
|
337 |
-
|
338 |
-
logger.info(f"Converting chapter {chapter_num} to audio...")
|
339 |
-
|
340 |
-
# Read the chapter text
|
341 |
-
with open(chapter_path, 'r', encoding='utf-8', errors='replace') as f:
|
342 |
-
chapter_text = f.read()
|
343 |
-
|
344 |
-
# Split into sentences for better processing
|
345 |
-
sentences = split_into_natural_sentences(chapter_text)
|
346 |
-
combined_audio = AudioSegment.empty()
|
347 |
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
continue
|
352 |
-
|
353 |
-
temp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
354 |
-
temp_wav.close()
|
355 |
-
|
356 |
-
try:
|
357 |
-
# Convert sentence to speech
|
358 |
-
if convert_text_to_speech(sentence, temp_wav.name, voice, speed, pitch, gap):
|
359 |
-
sentence_audio = AudioSegment.from_wav(temp_wav.name)
|
360 |
-
combined_audio += sentence_audio
|
361 |
-
|
362 |
-
# Add a small pause between sentences
|
363 |
-
combined_audio += AudioSegment.silent(duration=50)
|
364 |
-
else:
|
365 |
-
logger.warning(f"Failed to convert sentence in chapter {chapter_num}: {sentence[:50]}...")
|
366 |
-
except Exception as e:
|
367 |
-
logger.error(f"Error processing sentence: {e}")
|
368 |
-
finally:
|
369 |
-
# Clean up temporary file
|
370 |
-
if os.path.exists(temp_wav.name):
|
371 |
-
os.unlink(temp_wav.name)
|
372 |
-
|
373 |
-
# Export the combined audio for this chapter
|
374 |
-
if len(combined_audio) > 0:
|
375 |
-
combined_audio.export(output_file, format='wav')
|
376 |
-
logger.info(f"Saved audio for chapter {chapter_num}")
|
377 |
-
processed_chapters += 1
|
378 |
-
else:
|
379 |
-
logger.error(f"No audio generated for chapter {chapter_num}")
|
380 |
-
failed_chapters += 1
|
381 |
-
|
382 |
-
# Update progress
|
383 |
-
pbar.update(1)
|
384 |
-
if progress_callback:
|
385 |
-
try:
|
386 |
-
progress_callback((processed_chapters + failed_chapters) / total_chapters,
|
387 |
-
f"Processed {processed_chapters}/{total_chapters} chapters")
|
388 |
-
except Exception as e:
|
389 |
-
logger.warning(f"Progress callback error: {e}")
|
390 |
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
def create_m4b_audiobook(input_audio_dir, ebook_path, output_dir, title=None, author=None, progress_callback=None):
|
395 |
-
"""Create M4B audiobook from chapter audio files."""
|
396 |
-
ensure_directory(output_dir)
|
397 |
|
398 |
-
#
|
399 |
-
|
400 |
-
|
|
|
|
|
401 |
|
402 |
-
#
|
403 |
-
|
404 |
-
|
|
|
405 |
|
406 |
-
|
407 |
-
logger.error("No audio chapter files found")
|
408 |
-
return None
|
409 |
-
|
410 |
-
# Create temporary directory
|
411 |
-
temp_dir = tempfile.mkdtemp()
|
412 |
|
413 |
try:
|
414 |
-
#
|
415 |
-
|
416 |
-
|
417 |
|
418 |
-
|
419 |
-
|
|
|
420 |
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
# Extract cover
|
443 |
-
cover_image = extract_metadata_and_cover(ebook_path)
|
444 |
-
|
445 |
-
# Create metadata file
|
446 |
-
metadata_file = os.path.join(temp_dir, "metadata.txt")
|
447 |
-
with open(metadata_file, 'w') as f:
|
448 |
-
f.write(';FFMETADATA1\n')
|
449 |
-
if title:
|
450 |
-
f.write(f"title={title}\n")
|
451 |
-
if author:
|
452 |
-
f.write(f"artist={author}\n")
|
453 |
-
|
454 |
-
# Add chapters
|
455 |
-
for i, (start, duration, title) in enumerate(chapter_positions):
|
456 |
-
f.write(f'[CHAPTER]\nTIMEBASE=1/1000\nSTART={start}\n')
|
457 |
-
f.write(f'END={start + duration}\ntitle={title}\n')
|
458 |
|
459 |
-
#
|
460 |
-
|
|
|
461 |
|
462 |
-
|
463 |
-
|
464 |
-
|
465 |
-
|
466 |
-
|
|
|
|
|
|
|
|
|
|
|
467 |
|
468 |
-
|
|
|
469 |
|
470 |
-
|
471 |
-
|
472 |
-
subprocess.run(ffmpeg_cmd, check=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
|
473 |
-
logger.info(f"M4B file created successfully: {output_m4b}")
|
474 |
-
except subprocess.CalledProcessError as e:
|
475 |
-
logger.error(f"Error creating M4B file: {e}")
|
476 |
-
logger.error(f"STDERR: {e.stderr.decode('utf-8', errors='replace')}")
|
477 |
-
|
478 |
-
# Try simplified approach
|
479 |
-
logger.info("Trying simplified M4B creation...")
|
480 |
-
simple_cmd = ['ffmpeg', '-i', combined_wav, '-c:a', 'aac', '-b:a', '192k', output_m4b]
|
481 |
-
try:
|
482 |
-
subprocess.run(simple_cmd, check=True)
|
483 |
-
logger.info(f"M4B file created with simplified method: {output_m4b}")
|
484 |
-
except subprocess.CalledProcessError as e:
|
485 |
-
logger.error(f"Simplified M4B creation also failed: {e}")
|
486 |
-
return None
|
487 |
-
|
488 |
-
finally:
|
489 |
-
# Clean up temporary directory
|
490 |
-
shutil.rmtree(temp_dir)
|
491 |
-
|
492 |
-
if os.path.exists(output_m4b) and os.path.getsize(output_m4b) > 0:
|
493 |
-
return output_m4b
|
494 |
-
else:
|
495 |
-
logger.error("M4B file was not created or is empty")
|
496 |
-
return None
|
497 |
|
498 |
-
|
499 |
-
|
500 |
-
|
501 |
-
|
|
|
502 |
|
503 |
-
|
504 |
-
|
505 |
-
|
506 |
-
|
507 |
-
|
508 |
-
output_dir = os.path.join(base_dir, "Audiobooks")
|
509 |
|
510 |
-
|
511 |
-
|
512 |
|
513 |
-
|
514 |
-
|
515 |
-
|
|
|
|
|
|
|
516 |
|
517 |
-
# Create
|
518 |
-
|
519 |
-
|
520 |
-
|
|
|
521 |
|
522 |
-
|
523 |
-
|
|
|
|
|
|
|
524 |
|
525 |
-
|
526 |
-
|
527 |
-
try:
|
528 |
-
meta_result = subprocess.run(['ebook-meta', ebook_path],
|
529 |
-
stdout=subprocess.PIPE, text=True, check=False)
|
530 |
-
title_match = re.search(r'Title\s+:\s+(.*)', meta_result.stdout)
|
531 |
-
author_match = re.search(r'Author\(s\)\s+:\s+(.*)', meta_result.stdout)
|
532 |
-
title = title_match.group(1) if title_match else None
|
533 |
-
author = author_match.group(1) if author_match else None
|
534 |
-
except Exception as e:
|
535 |
-
logger.warning(f"Could not extract metadata: {e}")
|
536 |
-
title = author = None
|
537 |
-
|
538 |
-
# Process ebook to chapters
|
539 |
-
logger.info("Extracting chapters from ebook")
|
540 |
-
if progress and callable(progress):
|
541 |
-
progress(0.1, "Extracting chapters from ebook")
|
542 |
-
|
543 |
-
num_chapters = process_ebook_to_chapters(ebook_path, chapters_dir)
|
544 |
-
|
545 |
-
if num_chapters == 0:
|
546 |
-
return f"Failed to extract chapters from {ebook_name}", None
|
547 |
-
|
548 |
-
# Convert chapters to audio
|
549 |
-
logger.info("Converting text to speech")
|
550 |
-
if progress and callable(progress):
|
551 |
-
progress(0.3, "Converting text to speech")
|
552 |
-
|
553 |
-
def progress_callback(prog, desc):
|
554 |
-
if progress and callable(progress):
|
555 |
-
progress(0.3 + prog * 0.6, desc)
|
556 |
-
logger.info(f"Progress: {desc} ({prog*100:.1f}%)")
|
557 |
-
|
558 |
-
processed, failed = convert_chapters_to_audio(
|
559 |
-
chapters_dir,
|
560 |
-
audio_dir,
|
561 |
-
voice.split()[0],
|
562 |
-
int(speed),
|
563 |
-
int(pitch),
|
564 |
-
int(gap),
|
565 |
-
progress_callback
|
566 |
-
)
|
567 |
-
|
568 |
-
if processed == 0:
|
569 |
-
return f"Failed to convert any chapters to audio for {ebook_name}", None
|
570 |
-
|
571 |
-
# Create M4B audiobook
|
572 |
-
logger.info("Creating M4B audiobook")
|
573 |
-
if progress and callable(progress):
|
574 |
-
progress(0.9, "Creating M4B audiobook")
|
575 |
-
|
576 |
-
m4b_path = create_m4b_audiobook(audio_dir, ebook_path, output_dir, title, author)
|
577 |
-
|
578 |
-
if not m4b_path:
|
579 |
-
return f"Failed to create M4B file for {ebook_name}", None
|
580 |
-
|
581 |
-
# Conversion complete
|
582 |
-
elapsed_time = time.time() - start_time
|
583 |
-
|
584 |
-
if progress and callable(progress):
|
585 |
-
progress(1.0, "Conversion complete")
|
586 |
-
|
587 |
-
return f"Audiobook created: {os.path.basename(m4b_path)} (in {elapsed_time:.1f} seconds)", m4b_path
|
588 |
-
|
589 |
-
except Exception as e:
|
590 |
-
logger.error(f"Error converting ebook: {e}", exc_info=True)
|
591 |
-
return f"Error: {str(e)}", None
|
592 |
-
|
593 |
-
# Utility functions for Gradio interface
|
594 |
-
def get_available_voices():
|
595 |
-
"""Get list of available espeak-ng voices."""
|
596 |
-
try:
|
597 |
-
result = subprocess.run(['espeak-ng', '--voices'],
|
598 |
-
stdout=subprocess.PIPE, text=True, check=True)
|
599 |
-
lines = result.stdout.splitlines()[1:] # Skip header
|
600 |
-
|
601 |
-
voices = []
|
602 |
-
for line in lines:
|
603 |
-
parts = line.split()
|
604 |
-
if len(parts) > 3:
|
605 |
-
voice_id = parts[3] # Language code
|
606 |
-
description = ' '.join(parts[3:]) # Description
|
607 |
-
voices.append(f"{voice_id} ({description})")
|
608 |
-
|
609 |
-
return sorted(voices)
|
610 |
-
except Exception as e:
|
611 |
-
logger.error(f"Error getting voices: {e}")
|
612 |
-
# Return some default voices as fallback
|
613 |
-
return ["en (English)", "en-us (American English)", "en-gb (British English)"]
|
614 |
|
615 |
-
def
|
616 |
-
"""
|
617 |
-
|
618 |
-
|
619 |
-
|
620 |
-
files = []
|
621 |
-
for filename in os.listdir(output_dir):
|
622 |
-
if filename.endswith('.m4b'):
|
623 |
-
filepath = os.path.join(output_dir, filename)
|
624 |
-
files.append(filepath)
|
625 |
|
626 |
-
|
|
|
627 |
|
628 |
-
|
629 |
-
|
630 |
-
|
631 |
-
# Create theme
|
632 |
-
theme = gr.themes.Soft(
|
633 |
-
primary_hue="blue",
|
634 |
-
secondary_hue="green",
|
635 |
-
neutral_hue="slate",
|
636 |
-
text_size=gr.themes.sizes.text_md,
|
637 |
-
)
|
638 |
|
639 |
-
|
640 |
-
|
641 |
-
gr.Markdown(
|
642 |
-
"""
|
643 |
-
# 📚 eBook to Audiobook Converter
|
644 |
-
|
645 |
-
Convert any eBook format (EPUB, MOBI, PDF, etc.) to an M4B audiobook using eSpeak-NG.
|
646 |
-
|
647 |
-
## Features
|
648 |
-
- Automatic chapter detection
|
649 |
-
- Natural sentence splitting
|
650 |
-
- Multiple voice and language options
|
651 |
-
- Customizable speech settings
|
652 |
-
"""
|
653 |
-
)
|
654 |
|
655 |
with gr.Row():
|
656 |
-
with gr.Column(
|
657 |
-
|
658 |
-
|
659 |
-
|
660 |
-
|
661 |
-
|
662 |
-
|
663 |
-
|
664 |
-
|
665 |
-
|
666 |
-
|
667 |
-
|
668 |
-
|
669 |
-
|
670 |
-
|
671 |
-
|
672 |
-
|
673 |
-
|
674 |
-
|
675 |
-
value="en (English)",
|
676 |
-
info="Select language and voice variant"
|
677 |
-
)
|
678 |
-
|
679 |
convert_btn = gr.Button("Convert to Audiobook", variant="primary")
|
680 |
-
cancel_btn = gr.Button("Cancel Conversion", variant="stop")
|
681 |
-
|
682 |
-
with gr.Row():
|
683 |
-
with gr.Column(scale=1):
|
684 |
-
conversion_status = gr.Textbox(label="Conversion Status", interactive=False)
|
685 |
-
with gr.Column(scale=1):
|
686 |
-
audio_player = gr.Audio(label="Preview", type="filepath", interactive=False)
|
687 |
-
|
688 |
-
gr.Markdown("## Download Audiobooks")
|
689 |
-
with gr.Row():
|
690 |
-
refresh_btn = gr.Button("Refresh List")
|
691 |
-
download_btn = gr.Button("Download Selected File")
|
692 |
-
|
693 |
-
audiobook_files = gr.Dropdown(
|
694 |
-
choices=list_audiobooks(),
|
695 |
-
label="Available Audiobooks",
|
696 |
-
value=None,
|
697 |
-
interactive=True
|
698 |
-
)
|
699 |
-
|
700 |
-
# Define conversion task state
|
701 |
-
conversion_task = {"running": False, "thread": None}
|
702 |
-
|
703 |
-
# Handle events
|
704 |
-
def start_conversion(ebook_file, speed, pitch, voice, gap, progress=gr.Progress()):
|
705 |
-
# Check if already running
|
706 |
-
if conversion_task["running"]:
|
707 |
-
return "A conversion is already in progress. Please wait or cancel it.", None
|
708 |
|
709 |
-
|
710 |
-
|
711 |
-
|
712 |
-
conversion_task["running"] = True
|
713 |
-
|
714 |
-
# Define a simple progress wrapper that can handle both Gradio progress and None
|
715 |
-
def progress_wrapper(value, desc=None):
|
716 |
-
try:
|
717 |
-
if progress:
|
718 |
-
if desc:
|
719 |
-
return progress(value, desc)
|
720 |
-
else:
|
721 |
-
return progress(value)
|
722 |
-
except Exception as e:
|
723 |
-
logger.warning(f"Progress update failed: {e}")
|
724 |
-
|
725 |
-
result, output_path = convert_ebook_to_audiobook(ebook_file, speed, pitch, voice, gap, progress_wrapper)
|
726 |
-
conversion_task["running"] = False
|
727 |
-
|
728 |
-
return result, output_path
|
729 |
-
|
730 |
-
def cancel_current_conversion():
|
731 |
-
if conversion_task["running"]:
|
732 |
-
conversion_task["running"] = False
|
733 |
-
return "Conversion cancelled."
|
734 |
-
else:
|
735 |
-
return "No conversion is currently running."
|
736 |
|
737 |
-
def refresh_audiobook_list():
|
738 |
-
return gr.Dropdown.update(choices=list_audiobooks())
|
739 |
-
|
740 |
-
# Connect events
|
741 |
convert_btn.click(
|
742 |
-
|
743 |
-
inputs=[
|
744 |
-
outputs=[
|
745 |
-
)
|
746 |
-
|
747 |
-
cancel_btn.click(
|
748 |
-
cancel_current_conversion,
|
749 |
-
outputs=[conversion_status]
|
750 |
-
)
|
751 |
-
|
752 |
-
refresh_btn.click(
|
753 |
-
refresh_audiobook_list,
|
754 |
-
outputs=[audiobook_files]
|
755 |
)
|
756 |
|
757 |
-
|
758 |
-
|
759 |
-
|
760 |
-
|
761 |
-
|
|
|
|
|
762 |
|
763 |
-
|
764 |
-
|
765 |
-
|
766 |
-
)
|
767 |
|
768 |
-
|
769 |
-
|
770 |
-
return demo
|
771 |
-
|
772 |
-
# Command-line interface
|
773 |
-
def main():
|
774 |
-
"""Command-line entry point."""
|
775 |
-
parser = argparse.ArgumentParser(description='Convert eBooks to Audiobooks')
|
776 |
-
parser.add_argument('--gui', action='store_true', help='Launch graphical interface')
|
777 |
-
parser.add_argument('--port', type=int, default=7860, help='Port for web interface')
|
778 |
-
parser.add_argument('--ebook', type=str, help='Path to eBook file')
|
779 |
-
parser.add_argument('--voice', default='en', help='eSpeak voice to use')
|
780 |
-
parser.add_argument('--speed', type=int, default=170, help='Speech speed')
|
781 |
-
parser.add_argument('--pitch', type=int, default=50, help='Voice pitch')
|
782 |
-
parser.add_argument('--gap', type=int, default=5, help='Word gap')
|
783 |
-
args = parser.parse_args()
|
784 |
|
785 |
-
|
786 |
-
|
787 |
-
|
788 |
-
|
789 |
-
|
790 |
-
def __init__(self, path):
|
791 |
-
self.name = path
|
792 |
-
|
793 |
-
print(f"Converting {args.ebook} to audiobook...")
|
794 |
-
|
795 |
-
# Create a simple progress function for terminal output
|
796 |
-
def simple_progress(value, desc=None):
|
797 |
-
if desc:
|
798 |
-
print(f"{desc} - {value*100:.1f}%")
|
799 |
-
else:
|
800 |
-
print(f"Progress: {value*100:.1f}%")
|
801 |
-
|
802 |
-
result, output_path = convert_ebook_to_audiobook(
|
803 |
-
FilePath(args.ebook),
|
804 |
-
args.speed,
|
805 |
-
args.pitch,
|
806 |
-
args.voice,
|
807 |
-
args.gap,
|
808 |
-
simple_progress
|
809 |
-
)
|
810 |
-
print(result)
|
811 |
-
else:
|
812 |
-
# Default to GUI if no arguments
|
813 |
-
create_gradio_interface()
|
|
|
1 |
+
import gradio as gr
|
2 |
import os
|
|
|
3 |
import subprocess
|
|
|
|
|
4 |
import tempfile
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
import threading
|
|
|
|
|
6 |
import time
|
7 |
+
from pathlib import Path
|
8 |
+
from tqdm import tqdm
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
+
def get_espeak_voices():
|
11 |
+
"""Get available espeak-ng voices."""
|
|
|
|
|
|
|
12 |
try:
|
13 |
+
result = subprocess.run(['espeak-ng', '--voices'], capture_output=True, text=True)
|
14 |
+
voices = []
|
15 |
+
for line in result.stdout.splitlines()[1:]: # Skip header line
|
16 |
+
parts = line.split()
|
17 |
+
if len(parts) >= 4:
|
18 |
+
voice_name = parts[3]
|
19 |
+
voices.append(voice_name)
|
20 |
+
return sorted(voices)
|
|
|
|
|
21 |
except Exception as e:
|
22 |
+
print(f"Error getting espeak voices: {e}")
|
23 |
+
return ["default"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
|
25 |
+
def convert_ebook_to_txt(ebook_path):
|
26 |
+
"""Convert ebook to txt using Calibre's ebook-convert."""
|
27 |
+
temp_dir = tempfile.mkdtemp()
|
28 |
+
txt_path = os.path.join(temp_dir, "converted_book.txt")
|
29 |
+
|
30 |
try:
|
31 |
+
subprocess.run(
|
32 |
+
["ebook-convert", ebook_path, txt_path],
|
|
|
|
|
|
|
|
|
33 |
check=True,
|
34 |
+
stdout=subprocess.PIPE,
|
35 |
+
stderr=subprocess.PIPE
|
36 |
)
|
37 |
+
return txt_path
|
|
|
38 |
except subprocess.CalledProcessError as e:
|
39 |
+
print(f"Error converting ebook: {e}")
|
40 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
41 |
|
42 |
+
def count_words_in_file(file_path):
|
43 |
+
"""Count the number of words in a text file."""
|
44 |
try:
|
45 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
46 |
+
content = f.read()
|
47 |
+
return len(content.split())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
except Exception as e:
|
49 |
+
print(f"Error counting words: {e}")
|
50 |
+
return 0
|
51 |
|
52 |
+
def create_audiobook(progress_callback, ebook_path, voice, speech_rate, output_dir):
|
53 |
+
"""Convert ebook to audiobook using espeak-ng with progress bar."""
|
54 |
+
if not os.path.exists(ebook_path):
|
55 |
+
return f"Error: File {ebook_path} not found."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
56 |
|
57 |
+
# Create output directory if it doesn't exist
|
58 |
+
if not os.path.exists(output_dir):
|
59 |
+
os.makedirs(output_dir)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
|
61 |
+
book_name = os.path.splitext(os.path.basename(ebook_path))[0]
|
62 |
+
output_path = os.path.join(output_dir, f"{book_name}.wav")
|
|
|
|
|
|
|
|
|
63 |
|
64 |
+
# Convert ebook to text
|
65 |
+
progress_callback(0, "Converting ebook to text...")
|
66 |
+
txt_path = convert_ebook_to_txt(ebook_path)
|
67 |
+
if not txt_path:
|
68 |
+
return "Error: Failed to convert ebook to text."
|
69 |
|
70 |
+
# Count words for progress estimation
|
71 |
+
word_count = count_words_in_file(txt_path)
|
72 |
+
if word_count == 0:
|
73 |
+
return "Error: No text content found in the ebook."
|
74 |
|
75 |
+
progress_callback(10, f"Starting audio conversion of {word_count} words...")
|
|
|
|
|
|
|
|
|
|
|
76 |
|
77 |
try:
|
78 |
+
# Process text in chunks to show progress
|
79 |
+
with open(txt_path, 'r', encoding='utf-8') as f:
|
80 |
+
content = f.read()
|
81 |
|
82 |
+
# Create temporary directory for audio chunks
|
83 |
+
temp_audio_dir = tempfile.mkdtemp()
|
84 |
+
chunks = split_text_into_chunks(content)
|
85 |
|
86 |
+
# Convert each chunk with progress tracking
|
87 |
+
for i, chunk in enumerate(tqdm(chunks, desc="Converting to audio")):
|
88 |
+
chunk_path = os.path.join(temp_audio_dir, f"chunk_{i:04d}.wav")
|
89 |
|
90 |
+
# Save chunk to temporary file
|
91 |
+
chunk_txt_path = os.path.join(temp_audio_dir, f"chunk_{i:04d}.txt")
|
92 |
+
with open(chunk_txt_path, 'w', encoding='utf-8') as f:
|
93 |
+
f.write(chunk)
|
94 |
+
|
95 |
+
# Convert chunk to audio
|
96 |
+
subprocess.run([
|
97 |
+
"espeak-ng",
|
98 |
+
"-v", voice,
|
99 |
+
"-s", str(speech_rate),
|
100 |
+
"-f", chunk_txt_path,
|
101 |
+
"-w", chunk_path
|
102 |
+
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
103 |
+
|
104 |
+
# Update progress (from 10% to 90%)
|
105 |
+
progress = 10 + int(80 * (i + 1) / len(chunks))
|
106 |
+
progress_callback(progress, f"Converting chunk {i+1}/{len(chunks)}...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
107 |
|
108 |
+
# Combine audio chunks into final audiobook
|
109 |
+
progress_callback(90, "Combining audio chunks...")
|
110 |
+
combine_audio_files(temp_audio_dir, output_path)
|
111 |
|
112 |
+
# Clean up temporary files
|
113 |
+
progress_callback(95, "Cleaning up temporary files...")
|
114 |
+
try:
|
115 |
+
os.remove(txt_path)
|
116 |
+
for file in os.listdir(temp_audio_dir):
|
117 |
+
os.remove(os.path.join(temp_audio_dir, file))
|
118 |
+
os.rmdir(temp_audio_dir)
|
119 |
+
os.rmdir(os.path.dirname(txt_path))
|
120 |
+
except Exception as e:
|
121 |
+
print(f"Warning: Could not clean up all temporary files: {e}")
|
122 |
|
123 |
+
progress_callback(100, "Conversion complete!")
|
124 |
+
return f"Audiobook created successfully at {output_path}"
|
125 |
|
126 |
+
except Exception as e:
|
127 |
+
return f"Error creating audiobook: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
128 |
|
129 |
+
def split_text_into_chunks(text, chunk_size=1000):
|
130 |
+
"""Split text into chunks of roughly equal size."""
|
131 |
+
words = text.split()
|
132 |
+
chunks = []
|
133 |
+
current_chunk = []
|
134 |
|
135 |
+
for word in words:
|
136 |
+
current_chunk.append(word)
|
137 |
+
if len(current_chunk) >= chunk_size:
|
138 |
+
chunks.append(" ".join(current_chunk))
|
139 |
+
current_chunk = []
|
|
|
140 |
|
141 |
+
if current_chunk:
|
142 |
+
chunks.append(" ".join(current_chunk))
|
143 |
|
144 |
+
return chunks
|
145 |
+
|
146 |
+
def combine_audio_files(audio_dir, output_file):
|
147 |
+
"""Combine multiple WAV files into a single audiobook."""
|
148 |
+
# List all audio chunks and sort them
|
149 |
+
audio_files = sorted([os.path.join(audio_dir, f) for f in os.listdir(audio_dir) if f.endswith('.wav')])
|
150 |
|
151 |
+
# Create a file list for ffmpeg
|
152 |
+
list_file = os.path.join(audio_dir, "file_list.txt")
|
153 |
+
with open(list_file, 'w') as f:
|
154 |
+
for audio_file in audio_files:
|
155 |
+
f.write(f"file '{audio_file}'\n")
|
156 |
|
157 |
+
# Use ffmpeg to concatenate the files
|
158 |
+
subprocess.run([
|
159 |
+
"ffmpeg", "-f", "concat", "-safe", "0",
|
160 |
+
"-i", list_file, "-c", "copy", output_file
|
161 |
+
], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
162 |
|
163 |
+
# Clean up the list file
|
164 |
+
os.remove(list_file)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
165 |
|
166 |
+
def process_book(ebook_path, voice, speech_rate, output_dir, progress=gr.Progress()):
|
167 |
+
"""Process the ebook conversion with progress tracking."""
|
168 |
+
def update_progress(percent, status):
|
169 |
+
progress(percent / 100, status)
|
|
|
|
|
|
|
|
|
|
|
|
|
170 |
|
171 |
+
result = create_audiobook(update_progress, ebook_path, voice, speech_rate, output_dir)
|
172 |
+
return result
|
173 |
|
174 |
+
def create_gui():
|
175 |
+
"""Create the Gradio UI for the ebook-to-audiobook converter."""
|
176 |
+
available_voices = get_espeak_voices()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
177 |
|
178 |
+
with gr.Blocks(title="Ebook to Audiobook Converter") as app:
|
179 |
+
gr.Markdown("# 📚 Ebook to Audiobook Converter")
|
180 |
+
gr.Markdown("Convert any ebook to an audiobook using espeak-ng. The progress is shown in the terminal.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
181 |
|
182 |
with gr.Row():
|
183 |
+
with gr.Column():
|
184 |
+
ebook_input = gr.File(label="Upload Ebook")
|
185 |
+
voice_dropdown = gr.Dropdown(
|
186 |
+
choices=available_voices,
|
187 |
+
value=available_voices[0] if available_voices else "default",
|
188 |
+
label="Select Voice"
|
189 |
+
)
|
190 |
+
speech_rate = gr.Slider(
|
191 |
+
minimum=80,
|
192 |
+
maximum=500,
|
193 |
+
value=175,
|
194 |
+
step=5,
|
195 |
+
label="Speech Rate (words per minute)"
|
196 |
+
)
|
197 |
+
output_dir = gr.Textbox(
|
198 |
+
label="Output Directory",
|
199 |
+
value=str(Path.home() / "audiobooks"),
|
200 |
+
placeholder="Enter the directory to save the audiobook"
|
201 |
+
)
|
|
|
|
|
|
|
|
|
202 |
convert_btn = gr.Button("Convert to Audiobook", variant="primary")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
203 |
|
204 |
+
with gr.Column():
|
205 |
+
output_text = gr.Textbox(label="Status", interactive=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
206 |
|
|
|
|
|
|
|
|
|
207 |
convert_btn.click(
|
208 |
+
fn=process_book,
|
209 |
+
inputs=[ebook_input, voice_dropdown, speech_rate, output_dir],
|
210 |
+
outputs=[output_text]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
211 |
)
|
212 |
|
213 |
+
gr.Markdown("""
|
214 |
+
## Instructions
|
215 |
+
1. Upload your ebook file (supported formats: epub, mobi, pdf, azw, etc.)
|
216 |
+
2. Select a voice from the dropdown
|
217 |
+
3. Adjust the speech rate if needed
|
218 |
+
4. Specify an output directory for the audiobook
|
219 |
+
5. Click "Convert to Audiobook"
|
220 |
|
221 |
+
## Requirements
|
222 |
+
- Calibre (for ebook conversion)
|
223 |
+
- espeak-ng (for text-to-speech)
|
224 |
+
- ffmpeg (for audio processing)
|
225 |
|
226 |
+
The progress will be displayed in the terminal with a tqdm progress bar.
|
227 |
+
""")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
228 |
|
229 |
+
return app
|
230 |
+
|
231 |
+
if __name__ == "__main__":
|
232 |
+
app = create_gui()
|
233 |
+
app.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|