Files
pdftoaudiobook/pdfaudio.py
2024-10-17 23:02:31 +02:00

429 lines
15 KiB
Python

#!/usr/bin/env python3
#start of script
import os
import sys
import argparse
import logging
import subprocess
from pathlib import Path
from datetime import timedelta
import fitz # PyMuPDF
from pydub import AudioSegment
from tqdm import tqdm
# Import Tkinter for file dialog
try:
from tkinter import Tk
from tkinter.filedialog import askopenfilename
except ImportError:
# Handle environments without Tkinter
Tk = None
askopenfilename = None
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
def parse_arguments():
parser = argparse.ArgumentParser(description='Convert a PDF book into an audiobook.')
parser.add_argument('pdf_file', nargs='?', help='Path to the PDF file.')
parser.add_argument('--start-page', type=int, default=1, help='TOC start page number.')
parser.add_argument('--end-page', type=int, default=5, help='TOC end page number.')
parser.add_argument('--content-start-page', type=int, default=6, help='Content start page number.')
parser.add_argument('--output-folder', type=str, default='output', help='Folder to store output files.')
parser.add_argument('--voice', type=str, default='en', help='Voice code to use for speech (default is "en").')
parser.add_argument('--rate', type=int, default=170, help='Speech rate (default is 170 words per minute).')
parser.add_argument('--volume', type=int, default=100, help='Volume level (0 to 200, default is 100).')
parser.add_argument('--verbose', action='store_true', help='Enable verbose logging.')
return parser.parse_args()
def setup_logging(verbose):
if verbose:
logging.getLogger().setLevel(logging.DEBUG)
def extract_toc_from_pdf(pdf_file, start_page, end_page):
"""
Extracts the table of contents from specified pages of a PDF file.
Args:
pdf_file (str): Path to the PDF file.
start_page (int): Starting page number (1-indexed).
end_page (int): Ending page number (1-indexed).
Returns:
str: Extracted TOC text.
"""
try:
pdf = fitz.open(pdf_file)
except Exception as e:
logging.error(f"Error opening PDF file: {e}")
sys.exit(1)
toc_text = ""
for page_num in range(start_page - 1, end_page):
try:
page = pdf.load_page(page_num)
toc_text += page.get_text()
logging.debug(f"Extracted text from page {page_num + 1}")
except Exception as e:
logging.error(f"Error extracting text from page {page_num + 1}: {e}")
logging.info("TOC extraction complete.")
return toc_text
def parse_toc(toc_text):
"""
Parses the TOC text to identify chapters.
Args:
toc_text (str): Extracted TOC text.
Returns:
list: List of chapter titles.
"""
logging.info("Analyzing the table of contents for chapters...")
lines = toc_text.splitlines()
chapters = []
for line in lines:
line = line.strip()
if len(line) > 0:
chapters.append(line)
logging.info(f"Identified {len(chapters)} chapters.")
return chapters
def extract_chapter_texts(pdf_file, chapters, content_start_page):
"""
Extracts text for each chapter from the PDF.
Args:
pdf_file (str): Path to the PDF file.
chapters (list): List of chapter titles.
content_start_page (int): Starting page number for content (1-indexed).
Returns:
list: List of tuples containing chapter titles and their corresponding text.
"""
logging.info("Extracting the content for each chapter from the PDF...")
pdf = fitz.open(pdf_file)
total_pages = pdf.page_count
extracted_chapters = []
# Map chapters to their starting pages
chapter_pages = {}
logging.info("Mapping chapters to their starting pages...")
for page_num in tqdm(range(content_start_page - 1, total_pages), desc="Scanning pages"):
page = pdf.load_page(page_num)
text = page.get_text()
for chapter in chapters:
if chapter in text and chapter not in chapter_pages:
chapter_pages[chapter] = page_num
logging.debug(f"Found chapter '{chapter}' on page {page_num + 1}")
# Sort chapters based on their starting pages
sorted_chapters = sorted(chapter_pages.items(), key=lambda x: x[1])
# Extract text for each chapter
for idx, (chapter, start_page) in enumerate(sorted_chapters):
end_page = total_pages
if idx + 1 < len(sorted_chapters):
end_page = sorted_chapters[idx + 1][1]
chapter_text = ""
for page_num in range(start_page, end_page):
page = pdf.load_page(page_num)
chapter_text += page.get_text()
extracted_chapters.append((chapter, chapter_text))
logging.info(f"Extracted text for chapter '{chapter}' from pages {start_page + 1} to {end_page}")
logging.info("Chapter content extraction completed.")
return extracted_chapters
def save_chapters(extracted_chapters, text_folder):
"""
Saves each chapter's text into individual text files.
Args:
extracted_chapters (list): List of tuples containing chapter titles and text.
text_folder (Path): Path to the folder where text files will be saved.
"""
logging.info("Saving chapters into individual text files...")
for idx, (chapter_title, chapter_text) in enumerate(extracted_chapters):
safe_title = ''.join(c for c in chapter_title if c.isalnum() or c in (' ', '_')).rstrip()
file_name = text_folder / f"chapter_{idx + 1}_{safe_title}.txt"
try:
with open(file_name, "w", encoding='utf-8') as text_file:
text_file.write(chapter_text)
logging.debug(f"Saved chapter '{chapter_title}' to '{file_name}'")
except Exception as e:
logging.error(f"Error saving chapter '{chapter_title}': {e}")
logging.info("All chapters saved successfully.")
def text_to_speech(args):
"""
Converts text to speech using eSpeak via subprocess.
Args:
args (tuple): Contains text_file (Path), audio_file (Path), voice (str), rate (int), volume (int)
"""
text_file, audio_file, voice, rate, volume = args
try:
with open(text_file, "r", encoding='utf-8') as f:
text = f.read()
logging.info(f"Converting '{text_file}' to audio using eSpeak...")
# Prepare the text file for eSpeak
temp_text_file = text_file.parent / "temp_text.txt"
with open(temp_text_file, "w", encoding='utf-8') as f:
f.write(text)
# Prepare the command for eSpeak
temp_wav_file = audio_file.with_suffix('.wav')
command = [
'espeak',
'-v', voice,
'-s', str(rate),
'-a', str(volume),
'-f', str(temp_text_file),
'-w', str(temp_wav_file)
]
# Execute the command
subprocess.run(command, check=True)
# Convert WAV to MP3
audio_segment = AudioSegment.from_wav(temp_wav_file)
audio_segment.export(audio_file, format="mp3")
# Remove temporary files
temp_wav_file.unlink()
temp_text_file.unlink()
logging.info(f"Audio saved: {audio_file}")
except Exception as e:
logging.error(f"Error converting text to speech for '{text_file}': {e}")
# Remove the problematic audio file if it exists
if audio_file.exists():
audio_file.unlink()
# Re-raise the exception to stop further processing if needed
raise
def create_chapters_metadata(audio_files, output_folder):
"""
Creates a metadata file with chapter timings in ffmetadata format.
Args:
audio_files (list): List of audio file paths.
output_folder (Path): Path to the output folder.
Returns:
list: List of valid audio files
"""
logging.info("Generating metadata for chapters...")
chapters_txt = output_folder / "chapters.txt"
try:
with open(chapters_txt, "w", encoding='utf-8') as chapter_file:
# Write ffmetadata header
chapter_file.write(";FFMETADATA1\n\n")
time_in_ms = 0
valid_audio_files = []
for idx, audio_file in enumerate(audio_files):
if not audio_file.exists():
logging.warning(f"Audio file '{audio_file}' does not exist. Skipping.")
continue
try:
audio = AudioSegment.from_mp3(audio_file)
except Exception as e:
logging.error(f"Error reading audio file '{audio_file}': {e}")
continue
duration_ms = len(audio)
chapter_name = audio_file.stem
chapter_file.write("[CHAPTER]\n")
chapter_file.write("TIMEBASE=1/1000\n")
chapter_file.write(f"START={time_in_ms}\n")
time_in_ms += duration_ms
chapter_file.write(f"END={time_in_ms}\n")
chapter_file.write(f"title={chapter_name}\n\n")
valid_audio_files.append(audio_file)
if not valid_audio_files:
logging.error("No valid audio files found for metadata creation.")
sys.exit(1)
logging.info("Chapter metadata created.")
return valid_audio_files # Return only the valid audio files
except Exception as e:
logging.error(f"Error creating chapter metadata: {e}")
sys.exit(1)
def merge_audio_with_chapters(audio_files, output_folder):
"""
Merges audio files into a single audiobook with chapters.
Args:
audio_files (list): List of valid audio file paths.
output_folder (Path): Path to the output folder.
"""
if not audio_files:
logging.error("No valid audio files available for merging.")
sys.exit(1)
logging.info("Combining audio files into a single audiobook with chapters...")
# Create a list file for ffmpeg to concatenate audio
filelist_path = output_folder / "filelist.txt"
try:
with open(filelist_path, "w", encoding='utf-8') as f:
for audio_file in audio_files:
# Escape single quotes in file paths
file_path = str(audio_file.resolve()).replace("'", "'\\''")
f.write(f"file '{file_path}'\n")
logging.debug(f"File list for concatenation saved to '{filelist_path}'")
except Exception as e:
logging.error(f"Error creating file list for concatenation: {e}")
sys.exit(1)
# Merge audio using ffmpeg
output_audio = output_folder / "audiobook.mp3"
command_concat = [
"ffmpeg", "-f", "concat", "-safe", "0", "-i", str(filelist_path),
"-c", "copy", str(output_audio)
]
logging.info(f"Merging audio files into '{output_audio}'...")
run_ffmpeg_command(command_concat)
# Apply chapter metadata
chapters_txt = output_folder / "chapters.txt"
final_output = output_folder / "audiobook_with_chapters.mp3"
command_metadata = [
"ffmpeg", "-i", str(output_audio), "-i", str(chapters_txt),
"-map_metadata", "1", "-id3v2_version", "3", "-codec", "copy", str(final_output)
]
logging.info("Applying chapter metadata...")
run_ffmpeg_command(command_metadata)
logging.info(f"Audiobook created with chapters: '{final_output}'")
def run_ffmpeg_command(command):
"""
Runs an ffmpeg command and checks for errors.
Args:
command (list): List of command arguments.
"""
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError as e:
logging.error(f"FFmpeg command failed: {e}")
sys.exit(1)
def process_pdf_to_audiobook(args):
"""
Main function to process the PDF and create the audiobook.
Args:
args (argparse.Namespace): Parsed command-line arguments.
"""
pdf_file = Path(args.pdf_file)
if not pdf_file.is_file():
logging.error(f"PDF file '{pdf_file}' does not exist.")
sys.exit(1)
output_folder = Path(args.output_folder)
text_folder = output_folder / "text_files"
audio_folder = output_folder / "audio_files"
# Create necessary directories
text_folder.mkdir(parents=True, exist_ok=True)
audio_folder.mkdir(parents=True, exist_ok=True)
# Step 1: Extract TOC from known pages
toc_text = extract_toc_from_pdf(pdf_file, args.start_page, args.end_page)
# Step 2: Parse TOC and identify chapters
chapters = parse_toc(toc_text)
if not chapters:
logging.error("No chapters were identified. Please adjust the TOC parsing logic.")
sys.exit(1)
# Step 3: Extract and save chapter texts
extracted_chapters = extract_chapter_texts(pdf_file, chapters, args.content_start_page)
save_chapters(extracted_chapters, text_folder)
# Step 4: Convert each chapter to audio
text_files = sorted(text_folder.glob("*.txt"))
audio_files = []
tasks = []
for text_file in text_files:
audio_file = audio_folder / f"{text_file.stem}.mp3"
audio_files.append(audio_file)
tasks.append((text_file, audio_file, args.voice, args.rate, args.volume))
logging.info("Converting chapters to audio using eSpeak...")
try:
# Process chapters sequentially
for task in tqdm(tasks, total=len(tasks), desc="Converting to speech"):
text_to_speech(task)
except Exception as e:
logging.error(f"An error occurred during text-to-speech conversion: {e}")
logging.info("Terminating the process due to critical error.")
sys.exit(1)
if not any(audio_file.exists() for audio_file in audio_files):
logging.error("No audio files were generated. Something went wrong in the conversion step.")
sys.exit(1)
# Step 5: Create chapter metadata
audio_files = create_chapters_metadata(audio_files, output_folder)
# Step 6: Merge all audio files into a single audiobook
merge_audio_with_chapters(audio_files, output_folder)
logging.info("Process complete! All audio and text files are organized in the corresponding folders.")
logging.info(f"Audiobook available at: '{output_folder / 'audiobook_with_chapters.mp3'}'")
def main():
args = parse_arguments()
setup_logging(args.verbose)
if not args.pdf_file:
# Check if Tkinter is available
if Tk is None or askopenfilename is None:
logging.error("Tkinter is not available. Please install Tkinter or specify the PDF file as an argument.")
sys.exit(1)
logging.info("No PDF file specified, opening file dialog...")
try:
Tk().withdraw() # Hide the root window
downloads_folder = os.path.expanduser("~/Downloads")
pdf_file_path = askopenfilename(title="Select PDF file", initialdir=downloads_folder,
filetypes=[("PDF files", "*.pdf")])
if not pdf_file_path:
logging.error("No file selected. Exiting.")
sys.exit(1)
args.pdf_file = pdf_file_path
except Exception as e:
logging.error(f"Error opening file dialog: {e}")
sys.exit(1)
process_pdf_to_audiobook(args)
if __name__ == "__main__":
main()