這是用戶在 2025-1-15 22:40 為 https://claudio.uk/posts/epub-to-audiobook.html 保存的雙語快照頁面,由 沉浸式翻譯 提供雙語支持。了解如何保存?
Schedule a free call with me
預約與我免費通話

📚 Convert E-books into audiobooks with Kokoro
📚 使用 Kokoro 將電子書轉換成有聲書

Posted on 14 Jan 2025 by Claudio Santini
發布於 2025 年 1 月 14 日,作者:Claudio Santini

Kokoro v0.19 is a recently published text-to-speech model with just 82M params and very high-quality output. It's released under Apache licence and was trained on <100 hours of audio. It currently supports american, british english, french, korean, japanese and mandarin, in a bunch of very good voices.
Kokoro v0.19 是一款近期發表的文字轉語音模型,僅有 8200 萬個參數,卻擁有極高品質的輸出。它採用 Apache 授權釋出,訓練資料少於 100 小時的音訊。目前支援美式英語、英式英語、法語、韓語、日語和普通話,並提供多種優質語音。

An example of the quality:
音質範例:

I've always dreamed of converting my ebook library into audiobooks. Especially for those niche books that you cannot find in audiobook format. Since Kokoro is pretty fast, I thought this may finally be doable. I've created a small tool called Audiblez (in honor of the popular audiobook platform) that parses .epub files and converts the body of the book into nicely narrated audio files.
我一直夢想著將我的電子書庫轉換成有聲書,尤其是那些在有聲書格式中找不到的冷門書籍。由於 Kokoro 運算速度很快,我想這終於可以實現了。我創建了一個小型工具,稱為 Audiblez(向熱門有聲書平台致敬),它可以解析 .epub 檔案,並將書籍正文轉換成語音清晰的有聲檔案。

On my M2 MacBook Pro, it takes about 2 hours to convert to mp3 the Selfish Gene by Richard Dawkins, which is about 100,000 words (or 600,000 characters), at a rate of about 80 characters per second.
在我的 M2 MacBook Pro 上,將理查·道金斯的《自私的基因》(約 10 萬字,或 60 萬字元)轉換成 mp3 約需 2 小時,速度約為每秒 80 個字元。

How to install and run
如何安裝和執行

If you have Python 3 on your computer, you can install it with pip. Be aware that it won't work with Python 3.13.
如果你電腦上有 Python 3,你可以用 pip 安裝它。請注意,它不支援 Python 3.13。

Then you also need to download a couple of additional files in the same folder, which are about ~360MB:
接著,您還需要下載幾個約 ~360MB 的額外檔案到同一個資料夾中:

pip install audiblez
wget https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files/kokoro-v0_19.onnx
wget https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files/voices.json

Then, to convert an epub file into an audiobook, just run:
接著,要將 epub 檔案轉換成有聲書,只需執行:

audiblez book.epub -l en-gb -v af_sky

It will first create a bunch of book_chapter_1.wav, book_chapter_2.wav, etc. files in the same directory, and at the end it will produce a book.m4b file with the whole book you can listen with VLC or any audiobook player. It will only produce the .m4b file if you have ffmpeg installed on your machine.
它會先在同一個目錄中創建一堆 book_chapter_1.wavbook_chapter_2.wav 等檔案,最後會產生一個 book.m4b 檔案,裡面包含整本書,你可以用 VLC 或任何有聲書播放器收聽。只有在你電腦上安裝了 ffmpeg 的情況下,才會產生 .m4b 檔案。

Supported Languages  支援語言

Use -l option to specify the language, available language codes are: 🇺🇸 en-us, 🇬🇧 en-gb, 🇫🇷 fr-fr, 🇯🇵 ja, 🇰🇷 kr and 🇨🇳 cmn.
使用 -l 選項指定語言,可用的語言代碼為:🇺🇸 en-us 、🇬🇧 en-gb 、🇫🇷 fr-fr 、🇯🇵 ja 、🇰🇷 kr 和 🇨🇳 cmn

Supported Voices  支援語音

Use -v option to specify the voice: available voices are af, af_bella, af_nicole, af_sarah, af_sky, am_adam, am_michael, bf_emma, bf_isabella, bm_george, bm_lewis. You can try them here: https://huggingface.co/spaces/hexgrad/Kokoro-TTS
使用 -v 選項指定語音:可用的語音有 afaf_bellaaf_nicoleaf_sarahaf_skyam_adamam_michaelbf_emmabf_isabellabm_georgebm_lewis 。 你可以在這裡試聽:https://huggingface.co/spaces/hexgrad/Kokoro-TTS

Chapter Detection  章節偵測

Chapter detection is a bit janky, but it manages to find the core chapters in most .epub I tried, skipping the cover, index, appendix etc.
章節偵測有點粗糙,但它成功地在大部分我嘗試過的 .epub 檔案中找到了主要的章節,跳過了封面、索引、附錄等。

If you find it doesn't include the chapter you are interested into, try to play with the is_chapter function in the code. Often it skips the preface or intro, and I'm not sure if it's a bug or a feature.
如果你發現它沒有包含你感興趣的章節,可以嘗試修改程式碼中的 is_chapter 函數。它經常會跳過前言或引言,我不確定這是個錯誤還是功能。

Source  原始碼

See Audiblez project on GitHub.
查看 GitHub 上的 Audiblez 專案。

There are still some rough edges, but it works well enough for me. Future improvements could include:
還有些地方不夠完善,不過對我來說已經夠用了。未來的改進可以包含:

Code is short enough to be included here:
程式碼簡短到可以放在這裡:

#!/usr/bin/env python3
# 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

import argparse
import sys
import time
import shutil
import subprocess
import soundfile as sf
import ebooklib
import warnings
import re
from pathlib import Path
from string import Formatter
from bs4 import BeautifulSoup
from kokoro_onnx import Kokoro
from ebooklib import epub
from pydub import AudioSegment


def main(kokoro, file_path, lang, voice):
    filename = Path(file_path).name
    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)
    chapters = find_chapters(book)
    print('Found chapters:', [c.get_name() for c in chapters])
    texts = extract_texts(chapters)
    has_ffmpeg = shutil.which('ffmpeg') is not None
    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(' ')))

    i = 1
    chapter_mp3_files = []
    for text in texts:
        chapter_filename = filename.replace('.epub', f'_chapter_{i}.wav')
        chapter_mp3_files.append(chapter_filename)
        if Path(chapter_filename).exists():
            print(f'File for chapter {i} already exists. Skipping')
            i += 1
            continue
        print(f'Reading chapter {i} ({len(text):,} characters)...')
        if i == 1:
            text = intro + '.\n\n' + text
        start_time = time.time()
        samples, sample_rate = kokoro.create(text, voice=voice, speed=1.0, lang=lang)
        sf.write(f'{chapter_filename}', samples, sample_rate)
        end_time = time.time()
        delta_seconds = end_time - start_time
        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
        print(f'Estimated time remaining: {strfdelta(remaining_time)}')
        print('Chapter written to', chapter_filename)
        print(f'Chapter {i} read in {delta_seconds:.2f} seconds ({chars_per_sec:.0f} characters per second)')
        progress = int((total_chars - remaining_chars) / total_chars * 100)
        print('Progress:', f'{progress}%')
        i += 1
    if has_ffmpeg:
        create_m4b(chapter_mp3_files, filename)


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


def is_chapter(c):
    name = c.get_name().lower()
    part = r"part\d{1,3}"
    if re.search(part, name):
        return True
    ch = r"ch\d{1,3}"
    if re.search(ch, name):
        return True
    if 'chapter' in name:
        return True


def find_chapters(book, verbose=True):
    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:
                # 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]
    return chapters


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(chaptfer_files, filename):
    tmp_filename = filename.replace('.epub', '.tmp.m4a')
    if not Path(tmp_filename).exists():
        combined_audio = AudioSegment.empty()
        for wav_file in chaptfer_files:
            audio = AudioSegment.from_wav(wav_file)
            combined_audio += audio
        print('Converting to Mp4...')
        combined_audio.export(tmp_filename, format="mp4", codec="aac", bitrate="64k")
    final_filename = filename.replace('.epub', '.m4b')
    print('Creating M4B file...')
    proc = subprocess.run(['ffmpeg', '-i', f'{tmp_filename}', '-c', 'copy', '-f', 'mp4', f'{final_filename}'])
    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.')


def cli_main():
    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)
    kokoro = Kokoro('kokoro-v0_19.onnx', 'voices.json')
    voices = list(kokoro.get_voices())
    voices_str = ', '.join(voices)
    epilog = 'example:\n' + \
             '  audiblez book.epub -l en-us -v af_sky'
    default_voice = 'af_sky' if 'af_sky' in voices else voices[0]
    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')
    parser.add_argument('-v', '--voice', default=default_voice, help=f'Choose narrating voice: {voices_str}')
    if len(sys.argv) == 1:
        parser.print_help(sys.stderr)
        sys.exit(1)
    args = parser.parse_args()
    main(kokoro, args.epub_file_path, args.lang, args.voice)


if __name__ == '__main__':
    cli_main()