drewThomasson commited on
Commit
76feb60
·
verified ·
1 Parent(s): e08684e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +180 -760
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 json
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
- # eBook processing functions
123
- def extract_metadata_and_cover(ebook_path):
124
- """Extract metadata and cover image from an ebook."""
125
- cover_path = ebook_path.rsplit('.', 1)[0] + '.jpg'
126
-
127
  try:
128
- subprocess.run(['ebook-meta', ebook_path, '--get-cover', cover_path],
129
- check=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
130
-
131
- # Check if cover was extracted
132
- if os.path.exists(cover_path) and os.path.getsize(cover_path) > 0:
133
- logger.info(f"Cover extracted to: {cover_path}")
134
- return cover_path
135
- else:
136
- logger.warning("Cover extraction failed or resulted in empty file")
137
- return None
138
  except Exception as e:
139
- logger.error(f"Error extracting eBook metadata: {e}")
140
- return None
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 convert_to_text(input_path, output_path):
160
- """Convert any ebook format directly to TXT using Calibre."""
 
 
 
161
  try:
162
- logger.info(f"Converting {input_path} to TXT format...")
163
- result = subprocess.run(
164
- ['ebook-convert', input_path, output_path,
165
- '--enable-heuristics',
166
- '--chapter-mark=pagebreak',
167
- '--paragraph-type=unformatted'],
168
  check=True,
169
- stderr=subprocess.PIPE,
170
- stdout=subprocess.PIPE
171
  )
172
- logger.info(f"Successfully converted to TXT: {output_path}")
173
- return True
174
  except subprocess.CalledProcessError as e:
175
- logger.error(f"Error converting to TXT: {e}")
176
- logger.error(f"STDERR: {e.stderr.decode('utf-8', errors='replace')}")
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 convert_text_to_speech(text, output_path, voice="en", speed=170, pitch=50, gap=5):
282
- """Convert text to speech using espeak-ng."""
283
  try:
284
- # Create a temporary file for the text
285
- with tempfile.NamedTemporaryFile(mode='w+', encoding='utf-8', suffix='.txt', delete=False) as temp_file:
286
- temp_file.write(sanitize_for_espeak(text))
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
- logger.error(f"Unexpected error in speech synthesis: {e}")
316
- return False
317
 
318
- def convert_chapters_to_audio(chapters_dir, output_audio_dir, voice="en", speed=170, pitch=50, gap=5, progress_callback=None):
319
- """Convert all chapter text files to audio files."""
320
- ensure_directory(output_audio_dir)
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
- # Process each sentence
349
- for i, sentence in enumerate(sentences):
350
- if not sentence.strip():
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
- pbar.close()
392
- return processed_chapters, failed_chapters
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
- # Extract base name from ebook path
399
- base_name = os.path.splitext(os.path.basename(ebook_path))[0]
400
- output_m4b = os.path.join(output_dir, f"{base_name}.m4b")
 
 
401
 
402
- # Get chapter files
403
- chapter_files = [f for f in os.listdir(input_audio_dir) if f.startswith('audio_chapter_') and f.endswith('.wav')]
404
- chapter_files.sort(key=lambda f: int(re.search(r'audio_chapter_(\d+)', f).group(1)))
 
405
 
406
- if not chapter_files:
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
- # Combine audio files
415
- combined_wav = os.path.join(temp_dir, "combined.wav")
416
- combined_audio = AudioSegment.empty()
417
 
418
- chapter_positions = []
419
- current_position = 0
 
420
 
421
- for i, chapter_file in enumerate(chapter_files):
422
- chapter_path = os.path.join(input_audio_dir, chapter_file)
423
- logger.info(f"Adding chapter {i+1}/{len(chapter_files)} to audiobook")
424
 
425
- try:
426
- audio = AudioSegment.from_wav(chapter_path)
427
- chapter_positions.append((current_position, len(audio), f"Chapter {i+1}"))
428
- combined_audio += audio
429
- current_position += len(audio)
430
-
431
- # Add silence between chapters
432
- if i < len(chapter_files) - 1:
433
- silence = AudioSegment.silent(duration=1000) # 1 second
434
- combined_audio += silence
435
- current_position += 1000
436
- except Exception as e:
437
- logger.error(f"Error processing audio file {chapter_file}: {e}")
438
-
439
- # Export combined audio
440
- combined_audio.export(combined_wav, format="wav")
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
- # Create M4B file with ffmpeg
460
- ffmpeg_cmd = ['ffmpeg', '-i', combined_wav, '-i', metadata_file]
 
461
 
462
- if cover_image and os.path.exists(cover_image):
463
- ffmpeg_cmd += ['-i', cover_image, '-map', '0:a', '-map', '2:v']
464
- ffmpeg_cmd += ['-c:v', 'png', '-disposition:v', 'attached_pic']
465
- else:
466
- ffmpeg_cmd += ['-map', '0:a']
 
 
 
 
 
467
 
468
- ffmpeg_cmd += ['-map_metadata', '1', '-c:a', 'aac', '-b:a', '192k', output_m4b]
 
469
 
470
- # Execute ffmpeg command
471
- try:
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
- # Main conversion function
499
- def convert_ebook_to_audiobook(ebook_file, speed, pitch, voice, gap, progress=None):
500
- """Main function to convert ebook to audiobook."""
501
- start_time = time.time()
 
502
 
503
- # Initialize directories
504
- base_dir = os.path.abspath(os.path.dirname(__file__))
505
- working_dir = os.path.join(base_dir, "Working_files")
506
- chapters_dir = os.path.join(working_dir, "chapters")
507
- audio_dir = os.path.join(base_dir, "Chapter_wav_files")
508
- output_dir = os.path.join(base_dir, "Audiobooks")
509
 
510
- # Ensure output directory exists
511
- ensure_directory(output_dir)
512
 
513
- # Clean up previous files
514
- remove_directory(working_dir)
515
- remove_directory(audio_dir)
 
 
 
516
 
517
- # Create necessary directories
518
- ensure_directory(working_dir)
519
- ensure_directory(chapters_dir)
520
- ensure_directory(audio_dir)
 
521
 
522
- ebook_path = ebook_file.name
523
- ebook_name = os.path.basename(ebook_path)
 
 
 
524
 
525
- try:
526
- # Extract basic metadata if possible
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 list_audiobooks():
616
- """List all audiobooks in the output directory."""
617
- output_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), "Audiobooks")
618
- ensure_directory(output_dir)
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
- return sorted(files)
 
627
 
628
- # Gradio interface (continued)
629
- def create_gradio_interface(port=7860):
630
- """Create and launch Gradio interface."""
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
- # Create interface
640
- with gr.Blocks(theme=theme, title="eBook to Audiobook Converter") as demo:
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(scale=3):
657
- ebook_file = gr.File(label="eBook File", file_types=[".epub", ".mobi", ".azw", ".azw3", ".fb2", ".txt", ".pdf"])
658
-
659
- with gr.Row():
660
- with gr.Column(scale=1):
661
- speed = gr.Slider(minimum=80, maximum=450, value=170, step=1,
662
- label="Speech Speed", info="Higher values = faster speech")
663
- with gr.Column(scale=1):
664
- pitch = gr.Slider(minimum=0, maximum=99, value=50, step=1,
665
- label="Voice Pitch", info="Higher values = higher pitch")
666
-
667
- with gr.Row():
668
- with gr.Column(scale=1):
669
- gap = gr.Slider(minimum=0, maximum=20, value=5, step=1,
670
- label="Pause Length", info="Pause between words (ms)")
671
- with gr.Column(scale=1):
672
- voice_dropdown = gr.Dropdown(
673
- choices=get_available_voices(),
674
- label="Voice",
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
- if not ebook_file:
710
- return "Please select an eBook file first.", None
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
- start_conversion,
743
- inputs=[ebook_file, speed, pitch, voice_dropdown, gap],
744
- outputs=[conversion_status, audio_player]
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
- download_btn.click(
758
- lambda x: x,
759
- inputs=[audiobook_files],
760
- outputs=[audiobook_files]
761
- )
 
 
762
 
763
- ebook_file.upload(
764
- lambda: "eBook uploaded successfully",
765
- outputs=[conversion_status]
766
- )
767
 
768
- # Launch the interface
769
- demo.launch(server_port=port, share=True)
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
- if args.gui:
786
- create_gradio_interface(port=args.port)
787
- elif args.ebook:
788
- # Create a temporary file-like object for the ebook path
789
- class FilePath:
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()