audiblez/audiblez.py

218 lines
9 KiB
Python
Raw Normal View History

2025-01-14 17:45:04 +01:00
#!/usr/bin/env python3
2025-01-14 22:57:31 +01:00
# audiblez - A program to convert e-books into audiobooks using
# Kokoro-82M model for high-quality text-to-speech synthesis.
# by Claudio Santini 2025 - https://claudio.uk
2025-01-14 15:35:10 +01:00
import argparse
2025-01-14 17:45:04 +01:00
import sys
2025-01-14 15:35:10 +01:00
import time
import shutil
import subprocess
import soundfile as sf
import ebooklib
import warnings
2025-01-15 09:31:50 +01:00
import re
2025-01-14 15:35:10 +01:00
from pathlib import Path
from string import Formatter
from bs4 import BeautifulSoup
from kokoro_onnx import Kokoro
from ebooklib import epub
2025-01-14 18:38:26 +01:00
from pydub import AudioSegment
2025-01-15 19:12:48 +01:00
from pick import pick
import onnxruntime as ort
def main(kokoro, file_path, lang, voice, pick_manually, speed, providers):
# Set ONNX providers if specified
if providers:
available_providers = ort.get_available_providers()
invalid_providers = [p for p in providers if p not in available_providers]
if invalid_providers:
print(f"Invalid ONNX providers: {', '.join(invalid_providers)}")
print(f"Available providers: {', '.join(available_providers)}")
sys.exit(1)
kokoro.sess.set_providers(providers)
print(f"Using ONNX providers: {', '.join(providers)}")
2025-01-14 17:45:04 +01:00
filename = Path(file_path).name
2025-01-14 15:35:10 +01:00
with warnings.catch_warnings():
book = epub.read_epub(file_path)
title = book.get_metadata('DC', 'title')[0][0]
creator = book.get_metadata('DC', 'creator')[0][0]
intro = f'{title} by {creator}'
print(intro)
2025-01-15 19:12:48 +01:00
print('Found Chapters:', [c.get_name() for c in book.get_items() if c.get_type() == ebooklib.ITEM_DOCUMENT])
if pick_manually:
chapters = pick_chapters(book)
else:
chapters = find_chapters(book)
print('Selected chapters:', [c.get_name() for c in chapters])
2025-01-14 15:35:10 +01:00
texts = extract_texts(chapters)
has_ffmpeg = shutil.which('ffmpeg') is not None
2025-01-14 17:45:04 +01:00
if not has_ffmpeg:
print('\033[91m' + 'ffmpeg not found. Please install ffmpeg to create mp3 and m4b audiobook files.' + '\033[0m')
total_chars = sum([len(t) for t in texts])
print('Started at:', time.strftime('%H:%M:%S'))
print(f'Total characters: {total_chars:,}')
print('Total words:', len(' '.join(texts).split(' ')))
2025-01-14 15:35:10 +01:00
i = 1
2025-01-14 18:38:26 +01:00
chapter_mp3_files = []
2025-01-14 15:35:10 +01:00
for text in texts:
2025-01-15 12:37:33 +01:00
if len(text) == 0:
continue
2025-01-14 17:45:04 +01:00
chapter_filename = filename.replace('.epub', f'_chapter_{i}.wav')
2025-01-14 18:38:26 +01:00
chapter_mp3_files.append(chapter_filename)
2025-01-14 18:41:15 +01:00
if Path(chapter_filename).exists():
2025-01-14 15:35:10 +01:00
print(f'File for chapter {i} already exists. Skipping')
i += 1
continue
2025-01-16 15:19:45 +01:00
if len(text.strip()) < 10:
print(f'Skipping empty chapter {i}')
i += 1
continue
2025-01-14 15:35:10 +01:00
print(f'Reading chapter {i} ({len(text):,} characters)...')
2025-01-14 17:45:04 +01:00
if i == 1:
text = intro + '.\n\n' + text
2025-01-14 15:35:10 +01:00
start_time = time.time()
2025-01-15 22:05:49 +02:00
samples, sample_rate = kokoro.create(text, voice=voice, speed=speed, lang=lang)
2025-01-14 15:35:10 +01:00
sf.write(f'{chapter_filename}', samples, sample_rate)
end_time = time.time()
delta_seconds = end_time - start_time
2025-01-14 17:45:04 +01:00
chars_per_sec = len(text) / delta_seconds
remaining_chars = sum([len(t) for t in texts[i - 1:]])
remaining_time = remaining_chars / chars_per_sec
2025-01-14 15:35:10 +01:00
print(f'Estimated time remaining: {strfdelta(remaining_time)}')
2025-01-14 19:04:45 +01:00
print('Chapter written to', chapter_filename)
print(f'Chapter {i} read in {delta_seconds:.2f} seconds ({chars_per_sec:.0f} characters per second)')
2025-01-14 18:38:26 +01:00
progress = int((total_chars - remaining_chars) / total_chars * 100)
2025-01-14 19:04:45 +01:00
print('Progress:', f'{progress}%')
2025-01-14 15:35:10 +01:00
i += 1
2025-01-14 17:45:04 +01:00
if has_ffmpeg:
create_m4b(chapter_mp3_files, filename, title, creator)
2025-01-14 15:35:10 +01:00
def extract_texts(chapters):
texts = []
for chapter in chapters:
xml = chapter.get_body_content()
soup = BeautifulSoup(xml, features='lxml')
chapter_text = ''
html_content_tags = ['title', 'p', 'h1', 'h2', 'h3', 'h4']
for child in soup.find_all(html_content_tags):
inner_text = child.text.strip() if child.text else ""
if inner_text:
chapter_text += inner_text + '\n'
texts.append(chapter_text)
return texts
2025-01-15 09:31:50 +01:00
def is_chapter(c):
name = c.get_name().lower()
part = r"part\d{1,3}"
if re.search(part, name):
return True
2025-01-16 15:22:04 +01:00
ch = r"ch\d{1,3}"
if re.search(ch, name):
return True
chap = r"chap\d{1,3}"
if re.search(chap, name):
return True
2025-01-15 09:31:50 +01:00
if 'chapter' in name:
return True
2025-01-15 19:12:48 +01:00
def find_chapters(book, verbose=False):
2025-01-14 15:35:10 +01:00
chapters = [c for c in book.get_items() if c.get_type() == ebooklib.ITEM_DOCUMENT and is_chapter(c)]
if verbose:
for item in book.get_items():
if item.get_type() == ebooklib.ITEM_DOCUMENT:
2025-01-15 11:00:59 +01:00
print(f"'{item.get_name()}'" + ', #' + str(len(item.get_body_content())))
# print(f'{item.get_name()}'.ljust(60), str(len(item.get_body_content())).ljust(15), 'X' if item in chapters else '-')
if len(chapters) == 0:
print('Not easy to find the chapters, defaulting to all available documents.')
chapters = [c for c in book.get_items() if c.get_type() == ebooklib.ITEM_DOCUMENT]
2025-01-14 15:35:10 +01:00
return chapters
2025-01-15 19:12:48 +01:00
def pick_chapters(book):
2025-01-16 15:24:32 +01:00
all_chapters_names = [c.get_name() for c in book.get_items() if c.get_type() == ebooklib.ITEM_DOCUMENT]
2025-01-15 19:12:48 +01:00
title = 'Select which chapters to read in the audiobook'
selected_chapters_names = pick(all_chapters_names, title, multiselect=True, min_selection_count=1)
selected_chapters_names = [c[0] for c in selected_chapters_names]
selected_chapters = [c for c in book.get_items() if c.get_name() in selected_chapters_names]
return selected_chapters
2025-01-14 15:35:10 +01:00
def strfdelta(tdelta, fmt='{D:02}d {H:02}h {M:02}m {S:02}s'):
remainder = int(tdelta)
f = Formatter()
desired_fields = [field_tuple[1] for field_tuple in f.parse(fmt)]
possible_fields = ('W', 'D', 'H', 'M', 'S')
constants = {'W': 604800, 'D': 86400, 'H': 3600, 'M': 60, 'S': 1}
values = {}
for field in possible_fields:
if field in desired_fields and field in constants:
values[field], remainder = divmod(remainder, constants[field])
return f.format(fmt, **values)
def create_m4b(chapter_files, filename, title, author):
2025-01-14 18:38:26 +01:00
tmp_filename = filename.replace('.epub', '.tmp.m4a')
if not Path(tmp_filename).exists():
combined_audio = AudioSegment.empty()
for wav_file in chapter_files:
2025-01-14 18:38:26 +01:00
audio = AudioSegment.from_wav(wav_file)
combined_audio += audio
2025-01-14 18:41:15 +01:00
print('Converting to Mp4...')
2025-01-14 18:38:26 +01:00
combined_audio.export(tmp_filename, format="mp4", codec="aac", bitrate="64k")
final_filename = filename.replace('.epub', '.m4b')
2025-01-14 17:45:04 +01:00
print('Creating M4B file...')
proc = subprocess.run([
'ffmpeg', '-i', f'{tmp_filename}', '-c', 'copy', '-f', 'mp4',
'-metadata', f'title={title}',
'-metadata', f'author={author}',
f'{final_filename}'
])
2025-01-14 18:38:26 +01:00
Path(tmp_filename).unlink()
if proc.returncode == 0:
print(f'{final_filename} created. Enjoy your audiobook.')
print('Feel free to delete the intermediary .wav chapter files, the .m4b is all you need.')
2025-01-14 15:35:10 +01:00
2025-01-15 00:06:05 +01:00
def cli_main():
2025-01-14 23:22:06 +01:00
if not Path('kokoro-v0_19.onnx').exists() or not Path('voices.json').exists():
print('Error: kokoro-v0_19.onnx and voices.json must be in the current directory. Please download them with:')
print('wget https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files/kokoro-v0_19.onnx')
print('wget https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files/voices.json')
sys.exit(1)
2025-01-14 23:22:53 +01:00
kokoro = Kokoro('kokoro-v0_19.onnx', 'voices.json')
2025-01-14 15:35:10 +01:00
voices = list(kokoro.get_voices())
voices_str = ', '.join(voices)
epilog = 'example:\n' + \
' audiblez book.epub -l en-us -v af_sky'
2025-01-14 17:45:04 +01:00
default_voice = 'af_sky' if 'af_sky' in voices else voices[0]
# Get available ONNX providers
available_providers = ort.get_available_providers()
providers_help = f"Available ONNX providers: {', '.join(available_providers)}"
2025-01-14 15:35:10 +01:00
parser = argparse.ArgumentParser(epilog=epilog, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('epub_file_path', help='Path to the epub file')
parser.add_argument('-l', '--lang', default='en-gb', help='Language code: en-gb, en-us, fr-fr, ja, ko, cmn')
2025-01-14 17:45:04 +01:00
parser.add_argument('-v', '--voice', default=default_voice, help=f'Choose narrating voice: {voices_str}')
2025-01-16 15:25:04 +01:00
parser.add_argument('-p', '--pick', default=False, help=f'Interactively select which chapters to read in the audiobook',
2025-01-15 19:12:48 +01:00
action='store_true')
2025-01-16 15:20:51 +01:00
parser.add_argument('-s', '--speed', default=1.0, help=f'Set speed from 0.5 to 2.0', type=float)
parser.add_argument('--providers', nargs='+', metavar='PROVIDER', help=f"Specify ONNX providers. {providers_help}")
2025-01-14 17:45:04 +01:00
if len(sys.argv) == 1:
parser.print_help(sys.stderr)
sys.exit(1)
2025-01-14 15:35:10 +01:00
args = parser.parse_args()
main(kokoro, args.epub_file_path, args.lang, args.voice, args.pick, args.speed, args.providers)
2025-01-15 00:06:05 +01:00
if __name__ == '__main__':
cli_main()