Spaces:
Runtime error
Runtime error
Update app.py
Browse files
app.py
CHANGED
@@ -1,5 +1,3 @@
|
|
1 |
-
print("starting...")
|
2 |
-
|
3 |
import os
|
4 |
import shutil
|
5 |
import subprocess
|
@@ -13,411 +11,769 @@ import ebooklib
|
|
13 |
import bs4
|
14 |
from ebooklib import epub
|
15 |
from bs4 import BeautifulSoup
|
16 |
-
from gradio import Progress
|
17 |
-
import sys
|
18 |
from nltk.tokenize import sent_tokenize
|
19 |
import csv
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
-
def
|
|
|
40 |
if not os.path.exists(folder_path):
|
41 |
-
|
42 |
return
|
|
|
43 |
for item in os.listdir(folder_path):
|
44 |
item_path = os.path.join(folder_path, item)
|
45 |
-
if os.path.isfile(item_path):
|
46 |
-
os.remove(item_path)
|
47 |
-
elif os.path.isdir(item_path):
|
48 |
-
shutil.rmtree(item_path)
|
49 |
-
|
50 |
-
print(f"All contents wiped from {folder_path}.")
|
51 |
-
|
52 |
-
def create_m4b_from_chapters(input_dir, ebook_file, output_dir):
|
53 |
-
def sort_key(chapter_file):
|
54 |
-
numbers = re.findall(r'\d+', chapter_file)
|
55 |
-
return int(numbers[0]) if numbers else 0
|
56 |
-
|
57 |
-
def extract_metadata_and_cover(ebook_path):
|
58 |
try:
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
except Exception as e:
|
64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
65 |
return None
|
66 |
|
67 |
-
|
68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
75 |
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
85 |
|
86 |
-
|
87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
89 |
ffmpeg_cmd = ['ffmpeg', '-i', combined_wav, '-i', metadata_file]
|
90 |
-
|
|
|
91 |
ffmpeg_cmd += ['-i', cover_image, '-map', '0:a', '-map', '2:v']
|
92 |
ffmpeg_cmd += ['-c:v', 'png', '-disposition:v', 'attached_pic']
|
93 |
else:
|
94 |
ffmpeg_cmd += ['-map', '0:a']
|
95 |
|
96 |
-
ffmpeg_cmd += ['-map_metadata', '1', '-c:a', 'aac', '-b:a', '192k']
|
97 |
-
|
98 |
-
|
99 |
try:
|
100 |
-
subprocess.run(ffmpeg_cmd, check=True)
|
|
|
101 |
except subprocess.CalledProcessError as e:
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
ffmpeg_cmd += ['-c:v', 'png', '-disposition:v', 'attached_pic']
|
109 |
-
else:
|
110 |
-
ffmpeg_cmd += ['-map', '0:a']
|
111 |
-
|
112 |
-
ffmpeg_cmd += ['-c:a', 'aac', '-b:a', '192k']
|
113 |
try:
|
114 |
-
subprocess.run(
|
|
|
115 |
except subprocess.CalledProcessError as e:
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
try:
|
126 |
-
ffmpeg_cmd = ['ffmpeg', '-i', combined_wav, '-c:a', 'aac', '-b:a', '192k', output_m4b]
|
127 |
-
subprocess.run(ffmpeg_cmd, check=True)
|
128 |
-
except subprocess.CalledProcessError as e:
|
129 |
-
print(f"Final attempt failed: {e}")
|
130 |
-
print(f"M4B file created successfully at {output_m4b}")
|
131 |
-
|
132 |
-
|
133 |
-
chapter_files = sorted([os.path.join(input_dir, f) for f in os.listdir(input_dir) if f.endswith('.wav')], key=sort_key)
|
134 |
-
temp_dir = tempfile.gettempdir()
|
135 |
-
temp_combined_wav = os.path.join(temp_dir, 'combined.wav')
|
136 |
-
metadata_file = os.path.join(temp_dir, 'metadata.txt')
|
137 |
-
cover_image = extract_metadata_and_cover(ebook_file)
|
138 |
-
output_m4b = os.path.join(output_dir, os.path.splitext(os.path.basename(ebook_file))[0] + '.m4b')
|
139 |
-
|
140 |
-
combine_wav_files(chapter_files, temp_combined_wav)
|
141 |
-
generate_ffmpeg_metadata(chapter_files, metadata_file)
|
142 |
-
create_m4b(temp_combined_wav, metadata_file, cover_image, output_m4b)
|
143 |
-
|
144 |
-
if os.path.exists(temp_combined_wav):
|
145 |
-
os.remove(temp_combined_wav)
|
146 |
-
if os.path.exists(metadata_file):
|
147 |
-
os.remove(metadata_file)
|
148 |
-
if cover_image and os.path.exists(cover_image):
|
149 |
-
os.remove(cover_image)
|
150 |
-
|
151 |
-
def create_chapter_labeled_book(ebook_file_path):
|
152 |
-
def ensure_directory(directory_path):
|
153 |
-
if not os.path.exists(directory_path):
|
154 |
-
os.makedirs(directory_path)
|
155 |
-
print(f"Created directory: {directory_path}")
|
156 |
-
|
157 |
-
ensure_directory(os.path.join(".", 'Working_files', 'Book'))
|
158 |
-
|
159 |
-
def convert_to_epub(input_path, output_path):
|
160 |
-
try:
|
161 |
-
subprocess.run(['ebook-convert', input_path, output_path], check=True)
|
162 |
-
except subprocess.CalledProcessError as e:
|
163 |
-
print(f"An error occurred while converting the eBook: {e}")
|
164 |
-
return False
|
165 |
-
return True
|
166 |
-
|
167 |
-
def save_chapters_as_text(epub_path):
|
168 |
-
directory = os.path.join(".", "Working_files", "temp_ebook")
|
169 |
-
ensure_directory(directory)
|
170 |
-
|
171 |
-
book = epub.read_epub(epub_path)
|
172 |
-
|
173 |
-
previous_filename = ''
|
174 |
-
chapter_counter = 0
|
175 |
-
|
176 |
-
for item in book.get_items():
|
177 |
-
if item.get_type() == ebooklib.ITEM_DOCUMENT:
|
178 |
-
soup = BeautifulSoup(item.get_content(), 'html.parser')
|
179 |
-
text = soup.get_text()
|
180 |
-
|
181 |
-
if text.strip():
|
182 |
-
if len(text) < 2300 and previous_filename:
|
183 |
-
with open(previous_filename, 'a', encoding='utf-8') as file:
|
184 |
-
file.write('\n' + text)
|
185 |
-
else:
|
186 |
-
previous_filename = os.path.join(directory, f"chapter_{chapter_counter}.txt")
|
187 |
-
chapter_counter += 1
|
188 |
-
with open(previous_filename, 'w', encoding='utf-8') as file:
|
189 |
-
file.write(text)
|
190 |
-
print(f"Saved chapter: {previous_filename}")
|
191 |
-
|
192 |
-
input_ebook = ebook_file_path
|
193 |
-
output_epub = os.path.join(".", "Working_files", "temp.epub")
|
194 |
-
|
195 |
-
if os.path.exists(output_epub):
|
196 |
-
os.remove(output_epub)
|
197 |
-
print(f"File {output_epub} has been removed.")
|
198 |
else:
|
199 |
-
|
200 |
-
|
201 |
-
if convert_to_epub(input_ebook, output_epub):
|
202 |
-
save_chapters_as_text(output_epub)
|
203 |
-
|
204 |
-
# nltk.download('punkt')
|
205 |
-
|
206 |
-
def process_chapter_files(folder_path, output_csv):
|
207 |
-
with open(output_csv, 'w', newline='', encoding='utf-8') as csvfile:
|
208 |
-
writer = csv.writer(csvfile)
|
209 |
-
writer.writerow(['Text', 'Start Location', 'End Location', 'Is Quote', 'Speaker', 'Chapter'])
|
210 |
-
|
211 |
-
chapter_files = sorted(os.listdir(folder_path), key=lambda x: int(x.split('_')[1].split('.')[0]))
|
212 |
-
for filename in chapter_files:
|
213 |
-
if filename.startswith('chapter_') and filename.endswith('.txt'):
|
214 |
-
chapter_number = int(filename.split('_')[1].split('.')[0])
|
215 |
-
file_path = os.path.join(folder_path, filename)
|
216 |
-
|
217 |
-
try:
|
218 |
-
with open(file_path, 'r', encoding='utf-8') as file:
|
219 |
-
text = file.read()
|
220 |
-
if text:
|
221 |
-
text = "NEWCHAPTERABC" + text
|
222 |
-
sentences = nltk.tokenize.sent_tokenize(text)
|
223 |
-
for sentence in sentences:
|
224 |
-
start_location = text.find(sentence)
|
225 |
-
end_location = start_location + len(sentence)
|
226 |
-
writer.writerow([sentence, start_location, end_location, 'True', 'Narrator', chapter_number])
|
227 |
-
except Exception as e:
|
228 |
-
print(f"Error processing file {filename}: {e}")
|
229 |
-
|
230 |
-
folder_path = os.path.join(".", "Working_files", "temp_ebook")
|
231 |
-
output_csv = os.path.join(".", "Working_files", "Book", "Other_book.csv")
|
232 |
-
|
233 |
-
process_chapter_files(folder_path, output_csv)
|
234 |
-
|
235 |
-
def sort_key(filename):
|
236 |
-
match = re.search(r'chapter_(\d+)\.txt', filename)
|
237 |
-
return int(match.group(1)) if match else 0
|
238 |
-
|
239 |
-
def combine_chapters(input_folder, output_file):
|
240 |
-
os.makedirs(os.path.dirname(output_file), exist_ok=True)
|
241 |
-
|
242 |
-
files = [f for f in os.listdir(input_folder) if f.endswith('.txt')]
|
243 |
-
sorted_files = sorted(files, key=sort_key)
|
244 |
-
|
245 |
-
with open(output_file, 'w', encoding='utf-8') as outfile:
|
246 |
-
for i, filename in enumerate(sorted_files):
|
247 |
-
with open(os.path.join(input_folder, filename), 'r', encoding='utf-8') as infile:
|
248 |
-
outfile.write(infile.read())
|
249 |
-
if i < len(sorted_files) - 1:
|
250 |
-
outfile.write("\nNEWCHAPTERABC\n")
|
251 |
-
|
252 |
-
input_folder = os.path.join(".", 'Working_files', 'temp_ebook')
|
253 |
-
output_file = os.path.join(".", 'Working_files', 'Book', 'Chapter_Book.txt')
|
254 |
-
|
255 |
-
combine_chapters(input_folder, output_file)
|
256 |
-
|
257 |
-
ensure_directory(os.path.join(".", "Working_files", "Book"))
|
258 |
-
|
259 |
-
def sanitize_sentence(sentence):
|
260 |
-
# Replace or remove problematic characters that could cause issues in any language
|
261 |
-
sanitized = sentence.replace('--', ' ').replace('"', '').replace("'", "")
|
262 |
-
return sanitized
|
263 |
-
|
264 |
-
def convert_chapters_to_audio_espeak(chapters_dir, output_audio_dir, speed="170", pitch="50", voice="en"):
|
265 |
-
if not os.path.exists(output_audio_dir):
|
266 |
-
os.makedirs(output_audio_dir)
|
267 |
-
|
268 |
-
for chapter_file in sorted(os.listdir(chapters_dir)):
|
269 |
-
if chapter_file.endswith('.txt'):
|
270 |
-
match = re.search(r"chapter_(\d+).txt", chapter_file)
|
271 |
-
if match:
|
272 |
-
chapter_num = int(match.group(1))
|
273 |
-
else:
|
274 |
-
print(f"Skipping file {chapter_file} as it does not match the expected format.")
|
275 |
-
continue
|
276 |
-
|
277 |
-
chapter_path = os.path.join(chapters_dir, chapter_file)
|
278 |
-
output_file_name = f"audio_chapter_{chapter_num}.wav"
|
279 |
-
output_file_path = os.path.join(output_audio_dir, output_file_name)
|
280 |
-
|
281 |
-
with open(chapter_path, 'r', encoding='utf-8') as file:
|
282 |
-
chapter_text = file.read()
|
283 |
-
sentences = nltk.tokenize.sent_tokenize(chapter_text)
|
284 |
-
combined_audio = AudioSegment.empty()
|
285 |
-
|
286 |
-
for sentence in tqdm(sentences, desc=f"Chapter {chapter_num}"):
|
287 |
-
success = False
|
288 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as temp_wav:
|
289 |
-
try:
|
290 |
-
subprocess.run(["espeak-ng", "-v", voice, "-w", temp_wav.name, f"-s{speed}", f"-p{pitch}", sentence], check=True)
|
291 |
-
success = True
|
292 |
-
except subprocess.CalledProcessError:
|
293 |
-
# If it fails, try with the sanitized sentence
|
294 |
-
sanitized_sentence = sanitize_sentence(sentence)
|
295 |
-
try:
|
296 |
-
subprocess.run(["espeak-ng", "-v", voice, "-w", temp_wav.name, f"-s{speed}", f"-p{pitch}", sanitized_sentence], check=True)
|
297 |
-
success = True
|
298 |
-
print(f"Sanitized sentence used for: {sentence}")
|
299 |
-
except subprocess.CalledProcessError as e:
|
300 |
-
print(f"Failed to convert sentence to audio: {sentence}")
|
301 |
-
print(f"Error: {e}")
|
302 |
-
|
303 |
-
if success and os.path.getsize(temp_wav.name) > 0:
|
304 |
-
combined_audio += AudioSegment.from_wav(temp_wav.name)
|
305 |
-
else:
|
306 |
-
print(f"Skipping sentence due to failure or empty WAV: {sentence}")
|
307 |
-
os.remove(temp_wav.name)
|
308 |
-
|
309 |
-
combined_audio.export(output_file_path, format='wav')
|
310 |
-
print(f"Converted chapter {chapter_num} to audio.")
|
311 |
-
|
312 |
-
|
313 |
-
def convert_ebook_to_audio(ebook_file, speed, pitch, voice, progress=gr.Progress()):
|
314 |
-
ebook_file_path = ebook_file.name
|
315 |
-
working_files = os.path.join(".", "Working_files", "temp_ebook")
|
316 |
-
full_folder_working_files = os.path.join(".", "Working_files")
|
317 |
-
chapters_directory = os.path.join(".", "Working_files", "temp_ebook")
|
318 |
-
output_audio_directory = os.path.join(".", 'Chapter_wav_files')
|
319 |
-
remove_folder_with_contents(full_folder_working_files)
|
320 |
-
remove_folder_with_contents(output_audio_directory)
|
321 |
-
|
322 |
-
try:
|
323 |
-
progress(0.1, desc="Creating chapter-labeled book")
|
324 |
-
except Exception as e:
|
325 |
-
print(f"Error updating progress: {e}")
|
326 |
-
|
327 |
-
create_chapter_labeled_book(ebook_file_path)
|
328 |
-
audiobook_output_path = os.path.join(".", "Audiobooks")
|
329 |
-
|
330 |
-
try:
|
331 |
-
progress(0.3, desc="Converting chapters to audio")
|
332 |
-
except Exception as e:
|
333 |
-
print(f"Error updating progress: {e}")
|
334 |
-
|
335 |
-
convert_chapters_to_audio_espeak(chapters_directory, output_audio_directory, speed, pitch, voice.split()[0])
|
336 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
337 |
try:
|
338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
339 |
except Exception as e:
|
340 |
-
|
341 |
-
|
342 |
-
create_m4b_from_chapters(output_audio_directory, ebook_file_path, audiobook_output_path)
|
343 |
-
|
344 |
-
m4b_filename = os.path.splitext(os.path.basename(ebook_file_path))[0] + '.m4b'
|
345 |
-
m4b_filepath = os.path.join(audiobook_output_path, m4b_filename)
|
346 |
|
|
|
|
|
|
|
347 |
try:
|
348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
349 |
except Exception as e:
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
def
|
|
|
|
|
|
|
|
|
355 |
files = []
|
356 |
-
for filename in os.listdir(
|
357 |
if filename.endswith('.m4b'):
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
def download_audiobooks():
|
362 |
-
audiobook_output_path = os.path.join(".", "Audiobooks")
|
363 |
-
return list_audiobook_files(audiobook_output_path)
|
364 |
-
|
365 |
-
def get_available_voices():
|
366 |
-
result = subprocess.run(['espeak-ng', '--voices'], stdout=subprocess.PIPE, text=True)
|
367 |
-
lines = result.stdout.splitlines()[1:] # Skip the header line
|
368 |
-
voices = []
|
369 |
-
for line in lines:
|
370 |
-
parts = line.split()
|
371 |
-
if len(parts) > 1:
|
372 |
-
voice_name = parts[1]
|
373 |
-
description = ' '.join(parts[2:])
|
374 |
-
voices.append((voice_name, description))
|
375 |
-
return voices
|
376 |
-
|
377 |
-
theme = gr.themes.Soft(
|
378 |
-
primary_hue="blue",
|
379 |
-
secondary_hue="blue",
|
380 |
-
neutral_hue="blue",
|
381 |
-
text_size=gr.themes.sizes.text_md,
|
382 |
-
)
|
383 |
-
|
384 |
-
# Gradio UI setup
|
385 |
-
with gr.Blocks(theme=theme) as demo:
|
386 |
-
gr.Markdown(
|
387 |
-
"""
|
388 |
-
# eBook to Audiobook Converter
|
389 |
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
voices = get_available_voices()
|
402 |
-
voice_choices = [f"{voice} ({desc})" for voice, desc in voices]
|
403 |
-
voice_dropdown = gr.Dropdown(choices=voice_choices, label="Select Voice", value=voice_choices[0])
|
404 |
-
|
405 |
-
convert_btn = gr.Button("Convert to Audiobook", variant="primary")
|
406 |
-
output = gr.Textbox(label="Conversion Status")
|
407 |
-
audio_player = gr.Audio(label="Audiobook Player", type="filepath")
|
408 |
-
download_btn = gr.Button("Download Audiobook Files")
|
409 |
-
download_files = gr.File(label="Download Files", interactive=False)
|
410 |
-
|
411 |
-
convert_btn.click(
|
412 |
-
convert_ebook_to_audio,
|
413 |
-
inputs=[ebook_file, speed, pitch, voice_dropdown],
|
414 |
-
outputs=[output, audio_player]
|
415 |
-
)
|
416 |
-
|
417 |
-
download_btn.click(
|
418 |
-
download_audiobooks,
|
419 |
-
outputs=[download_files]
|
420 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
421 |
|
422 |
-
|
423 |
-
|
|
|
|
|
|
|
1 |
import os
|
2 |
import shutil
|
3 |
import subprocess
|
|
|
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 |
+
for chapter_file in chapter_files:
|
331 |
+
chapter_path = os.path.join(chapters_dir, chapter_file)
|
332 |
+
chapter_num = int(re.search(r'chapter_(\d+)', chapter_file).group(1))
|
333 |
+
output_file = os.path.join(output_audio_dir, f"audio_chapter_{chapter_num:03d}.wav")
|
334 |
+
|
335 |
+
logger.info(f"Converting chapter {chapter_num} to audio...")
|
336 |
+
|
337 |
+
# Read the chapter text
|
338 |
+
with open(chapter_path, 'r', encoding='utf-8', errors='replace') as f:
|
339 |
+
chapter_text = f.read()
|
340 |
+
|
341 |
+
# Split into sentences for better processing
|
342 |
+
sentences = split_into_natural_sentences(chapter_text)
|
343 |
+
combined_audio = AudioSegment.empty()
|
344 |
+
|
345 |
+
# Process each sentence
|
346 |
+
for i, sentence in enumerate(sentences):
|
347 |
+
if not sentence.strip():
|
348 |
+
continue
|
349 |
+
|
350 |
+
temp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
|
351 |
+
temp_wav.close()
|
352 |
+
|
353 |
+
try:
|
354 |
+
# Convert sentence to speech
|
355 |
+
if convert_text_to_speech(sentence, temp_wav.name, voice, speed, pitch, gap):
|
356 |
+
sentence_audio = AudioSegment.from_wav(temp_wav.name)
|
357 |
+
combined_audio += sentence_audio
|
358 |
+
|
359 |
+
# Add a small pause between sentences
|
360 |
+
combined_audio += AudioSegment.silent(duration=50)
|
361 |
+
else:
|
362 |
+
logger.warning(f"Failed to convert sentence in chapter {chapter_num}: {sentence[:50]}...")
|
363 |
+
except Exception as e:
|
364 |
+
logger.error(f"Error processing sentence: {e}")
|
365 |
+
finally:
|
366 |
+
# Clean up temporary file
|
367 |
+
if os.path.exists(temp_wav.name):
|
368 |
+
os.unlink(temp_wav.name)
|
369 |
+
|
370 |
+
# Export the combined audio for this chapter
|
371 |
+
if len(combined_audio) > 0:
|
372 |
+
combined_audio.export(output_file, format='wav')
|
373 |
+
logger.info(f"Saved audio for chapter {chapter_num}")
|
374 |
+
processed_chapters += 1
|
375 |
+
else:
|
376 |
+
logger.error(f"No audio generated for chapter {chapter_num}")
|
377 |
+
failed_chapters += 1
|
378 |
+
|
379 |
+
# Update progress
|
380 |
+
if progress_callback:
|
381 |
+
progress_callback((processed_chapters + failed_chapters) / total_chapters,
|
382 |
+
f"Processed {processed_chapters}/{total_chapters} chapters")
|
383 |
+
|
384 |
+
return processed_chapters, failed_chapters
|
385 |
+
|
386 |
+
def create_m4b_audiobook(input_audio_dir, ebook_path, output_dir, title=None, author=None, progress_callback=None):
|
387 |
+
"""Create M4B audiobook from chapter audio files."""
|
388 |
+
ensure_directory(output_dir)
|
389 |
+
|
390 |
+
# Extract base name from ebook path
|
391 |
+
base_name = os.path.splitext(os.path.basename(ebook_path))[0]
|
392 |
+
output_m4b = os.path.join(output_dir, f"{base_name}.m4b")
|
393 |
+
|
394 |
+
# Get chapter files
|
395 |
+
chapter_files = [f for f in os.listdir(input_audio_dir) if f.startswith('audio_chapter_') and f.endswith('.wav')]
|
396 |
+
chapter_files.sort(key=lambda f: int(re.search(r'audio_chapter_(\d+)', f).group(1)))
|
397 |
+
|
398 |
+
if not chapter_files:
|
399 |
+
logger.error("No audio chapter files found")
|
400 |
+
return None
|
401 |
+
|
402 |
+
# Create temporary directory
|
403 |
+
temp_dir = tempfile.mkdtemp()
|
404 |
+
|
405 |
+
try:
|
406 |
+
# Combine audio files
|
407 |
+
combined_wav = os.path.join(temp_dir, "combined.wav")
|
408 |
+
combined_audio = AudioSegment.empty()
|
409 |
+
|
410 |
+
chapter_positions = []
|
411 |
+
current_position = 0
|
412 |
+
|
413 |
+
for i, chapter_file in enumerate(chapter_files):
|
414 |
+
chapter_path = os.path.join(input_audio_dir, chapter_file)
|
415 |
+
logger.info(f"Adding chapter {i+1}/{len(chapter_files)} to audiobook")
|
416 |
+
|
417 |
+
try:
|
418 |
+
audio = AudioSegment.from_wav(chapter_path)
|
419 |
+
chapter_positions.append((current_position, len(audio), f"Chapter {i+1}"))
|
420 |
+
combined_audio += audio
|
421 |
+
current_position += len(audio)
|
422 |
+
|
423 |
+
# Add silence between chapters
|
424 |
+
if i < len(chapter_files) - 1:
|
425 |
+
silence = AudioSegment.silent(duration=1000) # 1 second
|
426 |
+
combined_audio += silence
|
427 |
+
current_position += 1000
|
428 |
+
except Exception as e:
|
429 |
+
logger.error(f"Error processing audio file {chapter_file}: {e}")
|
430 |
+
|
431 |
+
# Export combined audio
|
432 |
+
combined_audio.export(combined_wav, format="wav")
|
433 |
+
|
434 |
+
# Extract cover
|
435 |
+
cover_image = extract_metadata_and_cover(ebook_path)
|
436 |
+
|
437 |
+
# Create metadata file
|
438 |
+
metadata_file = os.path.join(temp_dir, "metadata.txt")
|
439 |
+
with open(metadata_file, 'w') as f:
|
440 |
+
f.write(';FFMETADATA1\n')
|
441 |
+
if title:
|
442 |
+
f.write(f"title={title}\n")
|
443 |
+
if author:
|
444 |
+
f.write(f"artist={author}\n")
|
445 |
+
|
446 |
+
# Add chapters
|
447 |
+
for i, (start, duration, title) in enumerate(chapter_positions):
|
448 |
+
f.write(f'[CHAPTER]\nTIMEBASE=1/1000\nSTART={start}\n')
|
449 |
+
f.write(f'END={start + duration}\ntitle={title}\n')
|
450 |
+
|
451 |
+
# Create M4B file with ffmpeg
|
452 |
ffmpeg_cmd = ['ffmpeg', '-i', combined_wav, '-i', metadata_file]
|
453 |
+
|
454 |
+
if cover_image and os.path.exists(cover_image):
|
455 |
ffmpeg_cmd += ['-i', cover_image, '-map', '0:a', '-map', '2:v']
|
456 |
ffmpeg_cmd += ['-c:v', 'png', '-disposition:v', 'attached_pic']
|
457 |
else:
|
458 |
ffmpeg_cmd += ['-map', '0:a']
|
459 |
|
460 |
+
ffmpeg_cmd += ['-map_metadata', '1', '-c:a', 'aac', '-b:a', '192k', output_m4b]
|
461 |
+
|
462 |
+
# Execute ffmpeg command
|
463 |
try:
|
464 |
+
subprocess.run(ffmpeg_cmd, check=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
|
465 |
+
logger.info(f"M4B file created successfully: {output_m4b}")
|
466 |
except subprocess.CalledProcessError as e:
|
467 |
+
logger.error(f"Error creating M4B file: {e}")
|
468 |
+
logger.error(f"STDERR: {e.stderr.decode('utf-8', errors='replace')}")
|
469 |
+
|
470 |
+
# Try simplified approach
|
471 |
+
logger.info("Trying simplified M4B creation...")
|
472 |
+
simple_cmd = ['ffmpeg', '-i', combined_wav, '-c:a', 'aac', '-b:a', '192k', output_m4b]
|
|
|
|
|
|
|
|
|
|
|
473 |
try:
|
474 |
+
subprocess.run(simple_cmd, check=True)
|
475 |
+
logger.info(f"M4B file created with simplified method: {output_m4b}")
|
476 |
except subprocess.CalledProcessError as e:
|
477 |
+
logger.error(f"Simplified M4B creation also failed: {e}")
|
478 |
+
return None
|
479 |
+
|
480 |
+
finally:
|
481 |
+
# Clean up temporary directory
|
482 |
+
shutil.rmtree(temp_dir)
|
483 |
+
|
484 |
+
if os.path.exists(output_m4b) and os.path.getsize(output_m4b) > 0:
|
485 |
+
return output_m4b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
486 |
else:
|
487 |
+
logger.error("M4B file was not created or is empty")
|
488 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
489 |
|
490 |
+
# Main conversion function
|
491 |
+
def convert_ebook_to_audiobook(ebook_file, speed, pitch, voice, gap, progress=None):
|
492 |
+
"""Main function to convert ebook to audiobook."""
|
493 |
+
start_time = time.time()
|
494 |
+
|
495 |
+
# Initialize directories
|
496 |
+
base_dir = os.path.abspath(os.path.dirname(__file__))
|
497 |
+
working_dir = os.path.join(base_dir, "Working_files")
|
498 |
+
chapters_dir = os.path.join(working_dir, "chapters")
|
499 |
+
audio_dir = os.path.join(base_dir, "Chapter_wav_files")
|
500 |
+
output_dir = os.path.join(base_dir, "Audiobooks")
|
501 |
+
|
502 |
+
# Ensure output directory exists
|
503 |
+
ensure_directory(output_dir)
|
504 |
+
|
505 |
+
# Clean up previous files
|
506 |
+
remove_directory(working_dir)
|
507 |
+
remove_directory(audio_dir)
|
508 |
+
|
509 |
+
# Create necessary directories
|
510 |
+
ensure_directory(working_dir)
|
511 |
+
ensure_directory(chapters_dir)
|
512 |
+
ensure_directory(audio_dir)
|
513 |
+
|
514 |
+
ebook_path = ebook_file.name
|
515 |
+
ebook_name = os.path.basename(ebook_path)
|
516 |
+
|
517 |
try:
|
518 |
+
# Extract basic metadata if possible
|
519 |
+
try:
|
520 |
+
meta_result = subprocess.run(['ebook-meta', ebook_path],
|
521 |
+
stdout=subprocess.PIPE, text=True, check=False)
|
522 |
+
title_match = re.search(r'Title\s+:\s+(.*)', meta_result.stdout)
|
523 |
+
author_match = re.search(r'Author\(s\)\s+:\s+(.*)', meta_result.stdout)
|
524 |
+
title = title_match.group(1) if title_match else None
|
525 |
+
author = author_match.group(1) if author_match else None
|
526 |
+
except Exception as e:
|
527 |
+
logger.warning(f"Could not extract metadata: {e}")
|
528 |
+
title = author = None
|
529 |
+
|
530 |
+
# Process ebook to chapters
|
531 |
+
if progress:
|
532 |
+
progress(0.1, desc="Extracting chapters from ebook")
|
533 |
+
|
534 |
+
num_chapters = process_ebook_to_chapters(ebook_path, chapters_dir)
|
535 |
+
|
536 |
+
if num_chapters == 0:
|
537 |
+
return f"Failed to extract chapters from {ebook_name}", None
|
538 |
+
|
539 |
+
# Convert chapters to audio
|
540 |
+
if progress:
|
541 |
+
progress(0.3, desc="Converting text to speech")
|
542 |
+
|
543 |
+
processed, failed = convert_chapters_to_audio(
|
544 |
+
chapters_dir,
|
545 |
+
audio_dir,
|
546 |
+
voice.split()[0],
|
547 |
+
int(speed),
|
548 |
+
int(pitch),
|
549 |
+
int(gap),
|
550 |
+
lambda prog, desc: progress(0.3 + prog * 0.6, desc=desc) if progress else None
|
551 |
+
)
|
552 |
+
|
553 |
+
if processed == 0:
|
554 |
+
return f"Failed to convert any chapters to audio for {ebook_name}", None
|
555 |
+
|
556 |
+
# Create M4B audiobook
|
557 |
+
if progress:
|
558 |
+
progress(0.9, desc="Creating M4B audiobook")
|
559 |
+
|
560 |
+
m4b_path = create_m4b_audiobook(audio_dir, ebook_path, output_dir, title, author)
|
561 |
+
|
562 |
+
if not m4b_path:
|
563 |
+
return f"Failed to create M4B file for {ebook_name}", None
|
564 |
+
|
565 |
+
# Conversion complete
|
566 |
+
elapsed_time = time.time() - start_time
|
567 |
+
|
568 |
+
if progress:
|
569 |
+
progress(1.0, desc="Conversion complete")
|
570 |
+
|
571 |
+
return f"Audiobook created: {os.path.basename(m4b_path)} (in {elapsed_time:.1f} seconds)", m4b_path
|
572 |
+
|
573 |
except Exception as e:
|
574 |
+
logger.error(f"Error converting ebook: {e}", exc_info=True)
|
575 |
+
return f"Error: {str(e)}", None
|
|
|
|
|
|
|
|
|
576 |
|
577 |
+
# Utility functions for Gradio interface
|
578 |
+
def get_available_voices():
|
579 |
+
"""Get list of available espeak-ng voices."""
|
580 |
try:
|
581 |
+
result = subprocess.run(['espeak-ng', '--voices'],
|
582 |
+
stdout=subprocess.PIPE, text=True, check=True)
|
583 |
+
lines = result.stdout.splitlines()[1:] # Skip header
|
584 |
+
|
585 |
+
voices = []
|
586 |
+
for line in lines:
|
587 |
+
parts = line.split()
|
588 |
+
if len(parts) > 3:
|
589 |
+
voice_id = parts[3] # Language code
|
590 |
+
description = ' '.join(parts[3:]) # Description
|
591 |
+
voices.append(f"{voice_id} ({description})")
|
592 |
+
|
593 |
+
return sorted(voices)
|
594 |
except Exception as e:
|
595 |
+
logger.error(f"Error getting voices: {e}")
|
596 |
+
# Return some default voices as fallback
|
597 |
+
return ["en (English)", "en-us (American English)", "en-gb (British English)"]
|
598 |
+
|
599 |
+
def list_audiobooks():
|
600 |
+
"""List all audiobooks in the output directory."""
|
601 |
+
output_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), "Audiobooks")
|
602 |
+
ensure_directory(output_dir)
|
603 |
+
|
604 |
files = []
|
605 |
+
for filename in os.listdir(output_dir):
|
606 |
if filename.endswith('.m4b'):
|
607 |
+
filepath = os.path.join(output_dir, filename)
|
608 |
+
files.append(filepath)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
609 |
|
610 |
+
return sorted(files)
|
611 |
+
|
612 |
+
# Gradio interface (continued)
|
613 |
+
def create_gradio_interface(port=7860):
|
614 |
+
"""Create and launch Gradio interface."""
|
615 |
+
# Create theme
|
616 |
+
theme = gr.themes.Soft(
|
617 |
+
primary_hue="blue",
|
618 |
+
secondary_hue="green",
|
619 |
+
neutral_hue="slate",
|
620 |
+
text_size=gr.themes.sizes.text_md,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
621 |
)
|
622 |
+
|
623 |
+
# Create interface
|
624 |
+
with gr.Blocks(theme=theme, title="eBook to Audiobook Converter") as demo:
|
625 |
+
gr.Markdown(
|
626 |
+
"""
|
627 |
+
# 📚 eBook to Audiobook Converter
|
628 |
+
|
629 |
+
Convert any eBook format (EPUB, MOBI, PDF, etc.) to an M4B audiobook using eSpeak-NG.
|
630 |
+
|
631 |
+
## Features
|
632 |
+
- Automatic chapter detection
|
633 |
+
- Natural sentence splitting
|
634 |
+
- Multiple voice and language options
|
635 |
+
- Customizable speech settings
|
636 |
+
"""
|
637 |
+
)
|
638 |
+
|
639 |
+
with gr.Row():
|
640 |
+
with gr.Column(scale=3):
|
641 |
+
ebook_file = gr.File(label="eBook File", file_types=[".epub", ".mobi", ".azw", ".azw3", ".fb2", ".txt", ".pdf"])
|
642 |
+
|
643 |
+
with gr.Row():
|
644 |
+
with gr.Column(scale=1):
|
645 |
+
speed = gr.Slider(minimum=80, maximum=450, value=170, step=1,
|
646 |
+
label="Speech Speed", info="Higher values = faster speech")
|
647 |
+
with gr.Column(scale=1):
|
648 |
+
pitch = gr.Slider(minimum=0, maximum=99, value=50, step=1,
|
649 |
+
label="Voice Pitch", info="Higher values = higher pitch")
|
650 |
+
|
651 |
+
with gr.Row():
|
652 |
+
with gr.Column(scale=1):
|
653 |
+
gap = gr.Slider(minimum=0, maximum=20, value=5, step=1,
|
654 |
+
label="Pause Length", info="Pause between words (ms)")
|
655 |
+
with gr.Column(scale=1):
|
656 |
+
voice_dropdown = gr.Dropdown(
|
657 |
+
choices=get_available_voices(),
|
658 |
+
label="Voice",
|
659 |
+
value="en (English)",
|
660 |
+
info="Select language and voice variant"
|
661 |
+
)
|
662 |
+
|
663 |
+
convert_btn = gr.Button("Convert to Audiobook", variant="primary")
|
664 |
+
cancel_btn = gr.Button("Cancel Conversion", variant="stop")
|
665 |
+
|
666 |
+
with gr.Row():
|
667 |
+
with gr.Column(scale=1):
|
668 |
+
conversion_status = gr.Textbox(label="Conversion Status", interactive=False)
|
669 |
+
with gr.Column(scale=1):
|
670 |
+
audio_player = gr.Audio(label="Preview", type="filepath", interactive=False)
|
671 |
+
|
672 |
+
gr.Markdown("## Download Audiobooks")
|
673 |
+
with gr.Row():
|
674 |
+
refresh_btn = gr.Button("Refresh List")
|
675 |
+
download_btn = gr.Button("Download Selected File")
|
676 |
+
|
677 |
+
audiobook_files = gr.Dropdown(
|
678 |
+
choices=list_audiobooks(),
|
679 |
+
label="Available Audiobooks",
|
680 |
+
value=None,
|
681 |
+
interactive=True
|
682 |
+
)
|
683 |
+
|
684 |
+
# Define conversion task state
|
685 |
+
conversion_task = {"running": False, "thread": None}
|
686 |
+
|
687 |
+
# Handle events
|
688 |
+
def start_conversion(ebook_file, speed, pitch, voice, gap, progress=gr.Progress()):
|
689 |
+
# Check if already running
|
690 |
+
if conversion_task["running"]:
|
691 |
+
return "A conversion is already in progress. Please wait or cancel it.", None
|
692 |
+
|
693 |
+
if not ebook_file:
|
694 |
+
return "Please select an eBook file first.", None
|
695 |
+
|
696 |
+
conversion_task["running"] = True
|
697 |
+
result, output_path = convert_ebook_to_audiobook(ebook_file, speed, pitch, voice, gap, progress)
|
698 |
+
conversion_task["running"] = False
|
699 |
+
|
700 |
+
return result, output_path
|
701 |
+
|
702 |
+
def cancel_current_conversion():
|
703 |
+
if conversion_task["running"]:
|
704 |
+
conversion_task["running"] = False
|
705 |
+
return "Conversion cancelled."
|
706 |
+
else:
|
707 |
+
return "No conversion is currently running."
|
708 |
+
|
709 |
+
def refresh_audiobook_list():
|
710 |
+
return gr.Dropdown.update(choices=list_audiobooks())
|
711 |
+
|
712 |
+
# Connect events
|
713 |
+
convert_btn.click(
|
714 |
+
start_conversion,
|
715 |
+
inputs=[ebook_file, speed, pitch, voice_dropdown, gap],
|
716 |
+
outputs=[conversion_status, audio_player]
|
717 |
+
)
|
718 |
+
|
719 |
+
cancel_btn.click(
|
720 |
+
cancel_current_conversion,
|
721 |
+
outputs=[conversion_status]
|
722 |
+
)
|
723 |
+
|
724 |
+
refresh_btn.click(
|
725 |
+
refresh_audiobook_list,
|
726 |
+
outputs=[audiobook_files]
|
727 |
+
)
|
728 |
+
|
729 |
+
download_btn.click(
|
730 |
+
lambda x: x,
|
731 |
+
inputs=[audiobook_files],
|
732 |
+
outputs=[audiobook_files]
|
733 |
+
)
|
734 |
+
|
735 |
+
ebook_file.upload(
|
736 |
+
lambda: "eBook uploaded successfully",
|
737 |
+
outputs=[conversion_status]
|
738 |
+
)
|
739 |
+
|
740 |
+
# Launch the interface
|
741 |
+
demo.launch(server_port=port, share=True)
|
742 |
+
return demo
|
743 |
+
|
744 |
+
# Command-line interface
|
745 |
+
def main():
|
746 |
+
"""Command-line entry point."""
|
747 |
+
parser = argparse.ArgumentParser(description='Convert eBooks to Audiobooks')
|
748 |
+
parser.add_argument('--gui', action='store_true', help='Launch graphical interface')
|
749 |
+
parser.add_argument('--port', type=int, default=7860, help='Port for web interface')
|
750 |
+
parser.add_argument('--ebook', type=str, help='Path to eBook file')
|
751 |
+
parser.add_argument('--voice', default='en', help='eSpeak voice to use')
|
752 |
+
parser.add_argument('--speed', type=int, default=170, help='Speech speed')
|
753 |
+
parser.add_argument('--pitch', type=int, default=50, help='Voice pitch')
|
754 |
+
parser.add_argument('--gap', type=int, default=5, help='Word gap')
|
755 |
+
args = parser.parse_args()
|
756 |
+
|
757 |
+
if args.gui:
|
758 |
+
create_gradio_interface(port=args.port)
|
759 |
+
elif args.ebook:
|
760 |
+
# Create a temporary file-like object for the ebook path
|
761 |
+
class FilePath:
|
762 |
+
def __init__(self, path):
|
763 |
+
self.name = path
|
764 |
+
|
765 |
+
print(f"Converting {args.ebook} to audiobook...")
|
766 |
+
result, output_path = convert_ebook_to_audiobook(
|
767 |
+
FilePath(args.ebook),
|
768 |
+
args.speed,
|
769 |
+
args.pitch,
|
770 |
+
args.voice,
|
771 |
+
args.gap
|
772 |
+
)
|
773 |
+
print(result)
|
774 |
+
else:
|
775 |
+
# Default to GUI if no arguments
|
776 |
+
create_gradio_interface()
|
777 |
|
778 |
+
if __name__ == "__main__":
|
779 |
+
main()
|