tts-kokoro engine underway

This commit is contained in:
2026-08-07 19:00:45 -03:00
parent a1f564a051
commit 2777b34026
9 changed files with 787 additions and 37 deletions
+12
View File
@@ -28,6 +28,18 @@ classifiers = [
dependencies = [
"load-dotenv>=0.1.0",
"beautifulsoup4",
"ebooklib",
"kokoro>=0.9.4",
"lxml",
"mutagen",
"nltk",
"numpy",
"pillow",
"pydub",
"soundfile",
"tqdm",
"audioop-lts; python_version >= '3.13'",
]
[project.optional-dependencies]
+1 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
import os
import pprint
def log (message):
def log (message, level=None):
pp = pprint.PrettyPrinter(
indent=4,
width=os.get_terminal_size().columns,
+74 -34
View File
@@ -23,18 +23,26 @@ optional arguments:
-b {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}, --backend {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}
Backend to use for TTS
"""
# stdlib
import os
import sys
from pathlib import Path
from argparse import ArgumentParser
# Pip packages
from dotenv import load_dotenv
from . import tts_aedocw, tts_generic
from . import log, PathLike
# Local imports
from . import tts_aedocw, tts_generic, tts_kokoro
from . import log, PathLike
from .utils import check_env
# Setup env
load_dotenv() # load environment variables from .env file if present
backend = {
# Globals
WHICH = ["ffmpeg"] # Neded in $PATH
BACKENDS = {
# keys are the backend names, values are the corresponding TTS classes
"default": tts_generic.GenericTTSBackend,
"edge": tts_aedocw.TTSEdge,
@@ -45,7 +53,8 @@ backend = {
"epub2tts-edge": tts_aedocw.AedocwEpub2TTSEdge,
"epub2tts-chatterbox": tts_aedocw.AedocwChatterbox,
"epub2tts-kokoro": tts_aedocw.AedocwKokoro,
"generic-epub2tts": tts_aedocw.Epub2TTS
"generic-epub2tts": tts_aedocw.Epub2TTS,
"kokoro": tts_kokoro.KokoroBackend,
}
@@ -57,42 +66,73 @@ def main(args=None):
action="version",
version=f"%(prog)s {__import__('epub_tts').__version__}",
)
p.add_argument("-i", "--input", help="Input EPUB file", required=True)
p.add_argument("-o", "--output", help="Output audio file", required=True)
p.add_argument("-l", "--language", help="Language to use for TTS (see your backend's documentation for available voices)")
p.add_argument("-v", "--voice", help="Voice to use for TTS (see your backend's documentation for available voices)")
p.add_argument("--speed", type=float, default=1.0, help="Playback speed multiplier (default: 1.0) (not all backends support this)")
p.add_argument("--short-pause", type=int, default=None, help="Short pause duration in milliseconds between phrases or sentences (not all backends support this)")
p.add_argument("--long-pause", type=int, default=None, help="Long pause duration in milliseconds between sections or paragraphs (not all backends support this)")
p.add_argument("-c", "--cover", help="Path to cover image to embed or use for output metadata")
p.add_argument("-r", "--replace", action="append", nargs=2, help="Replace text in the intermediate output. Specify pairs of old_text new_text. Can be used multiple times.")
p.add_argument(
"-b",
"--backend",
help="Backend to use for TTS",
default="default",
choices=backend.keys(),
)
# Input options and processing
p.add_argument("-i", "--input",
nargs="+", required=True,
help="Input EPUB file")
p.add_argument("-r", "--replace", action="append", nargs=2,
help="Replace text in the intermediate output. "
"Specify pairs of old_text new_text. "
"Can be used multiple times.")
p.add_argument("--check-env",action="store_true",
help="Check the runtime environment and exit")
p.add_argument("-b", "--backend", default="default",
choices=BACKENDS.keys(),
help="Backend to use for TTS")
# Output options
p.add_argument("-o", "--output",
help="Output audio file", required=True)
p.add_argument("-c", "--cover",
help="Path to cover image to embed or use "
"for output metadata")
# Speech options
p.add_argument("-l", "--language",
help="Language to use for TTS (see your "
"backend's documentation for available voices)")
p.add_argument("-v", "--voice",
help="Voice to use for TTS (see your backend's "
"documentation for available voices)")
p.add_argument("--speed", type=float, default=1.0,
help="Playback speed multiplier (default: 1.0) "
"(not all backends support this)")
p.add_argument("--short-pause", type=int, default=None,
help="Short pause duration in milliseconds between "
"phrases or sentences (not all backends support this)")
p.add_argument("--long-pause", type=int, default=None,
help="Long pause duration in milliseconds between "
"sections or paragraphs (not all backends support this)")
p.add_argument("--notitles", action="store_true",
help="Do not read chapter titles")
# Parse
args = p.parse_args(args or sys.argv[1:])
setattr(args, 'replace_map',
{old: new for old, new in args.replace} if args.replace else None)
log(args)
# log(
# f"Converting {args.input} to {args.output} "
# f"using {args.backend} backend "
# "and args: " + ", ".join(
# f"{k}={v}" for k, v in vars(args).items()
# if k not in ("input", "output", "backend") and v is not None)
# )
#log(args.replace_map)
backend[args.backend](**vars(args)).run(
args.input, args.output,
**vars(args),
)
# Execute actions
if args.check_env:
check_env()
engine = BACKENDS[args.backend](**vars(args))
if len(args.input) > 1 and not output_dest.is_dir():
log(f"Output must be a directory when multiple input files are provided", level="error")
sys.exit(1)
for file in args.input:
log(file)
if not Path(file).exists():
log(f"Input file {file} does not exist", level="error")
sys.exit(1)
output_dest = Path(args.output)
engine.run(
file, output_dest,
**vars(args),
)
if __name__ == "__main__":
sys.exit(main())
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html)
"""Postprocess audio files into an audiobook.
"""
import subprocess
# stdlib modules
import os
import sys
# pip installed packages
import numpy as np
import soundfile
import torch
from tqdm import tqdm
from kokoro import KPipeline
from ebooklib import epub
import soundfile as sf
from mutagen import mp4
from pydub import AudioSegment
from mutagen import mp4
# Local imports
from .tts_generic import GenericTTSBackend
from . import log, PathLike
from .preprocess import preprocess_book
def generate_metadata(files, author, title, chapter_titles):
chap = 0
start_time = 0
with open("FFMETADATAFILE", "w") as file:
file.write(";FFMETADATA1\n")
file.write(f"ARTIST={author}\n")
file.write(f"ALBUM={title}\n")
file.write(f"TITLE={title}\n")
file.write("DESCRIPTION=Made with https://github.com/aedocw/epub2tts-kokoro\n")
for file_name in files:
duration = get_duration(file_name)
file.write("[CHAPTER]\n")
file.write("TIMEBASE=1/1000\n")
file.write(f"START={start_time}\n")
file.write(f"END={start_time + duration}\n")
file.write(f"title={chapter_titles[chap]}\n")
chap += 1
start_time += duration
def get_duration(file_path):
audio = AudioSegment.from_file(file_path)
duration_milliseconds = len(audio)
return duration_milliseconds
def make_m4b(files, sourcefile, speaker):
filelist = "filelist.txt"
basefile = sourcefile.replace(".txt", "")
outputm4a = f"{basefile} ({speaker}).m4a"
outputm4b = f"{basefile} ({speaker}).m4b"
with open(filelist, "w") as f:
for filename in files:
filename = filename.replace("'", "'\\''")
f.write(f"file '{filename}'\n")
ffmpeg_command = [
"ffmpeg",
"-f",
"concat",
"-safe",
"0",
"-i",
filelist,
"-codec:a",
"flac",
"-f",
"mp4",
"-strict",
"-2",
outputm4a,
]
subprocess.run(ffmpeg_command)
ffmpeg_command = [
"ffmpeg",
"-i",
outputm4a,
"-i",
"FFMETADATAFILE",
"-map_metadata",
"1",
"-codec",
"aac",
outputm4b,
]
subprocess.run(ffmpeg_command)
os.remove(filelist)
os.remove("FFMETADATAFILE")
os.remove(outputm4a)
for f in files:
os.remove(f)
return outputm4b
def add_cover(cover_img, filename):
try:
if os.path.isfile(cover_img):
m4b = mp4.MP4(filename)
cover_image = open(cover_img, "rb").read()
m4b["covr"] = [mp4.MP4Cover(cover_image)]
m4b.save()
else:
print(f"Cover image {cover_img} not found")
except:
print(f"Cover image {cover_img} not found")
+363
View File
@@ -0,0 +1,363 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html)
"""Preprocess epub books into txt files.
"""
# stdlib modules
import os
import sys
import re
import zipfile
# pip installed packages
import numpy as np
import warnings
from tqdm import tqdm
from bs4 import BeautifulSoup
import ebooklib
from ebooklib import epub
import soundfile as sf
from lxml import etree
from PIL import Image
import nltk
from nltk.tokenize import sent_tokenize
# Local imports
from .tts_generic import GenericTTSBackend
from . import log, PathLike
namespaces = {
"calibre":"http://calibre.kovidgoyal.net/2009/metadata",
"dc":"http://purl.org/dc/elements/1.1/",
"dcterms":"http://purl.org/dc/terms/",
"opf":"http://www.idpf.org/2007/opf",
"u":"urn:oasis:names:tc:opendocument:xmlns:container",
"xsi":"http://www.w3.org/2001/XMLSchema-instance",
}
warnings.filterwarnings("ignore", module="ebooklib.epub")
def ensure_punkt():
try:
nltk.data.find("tokenizers/punkt")
except LookupError:
nltk.download("punkt")
try:
nltk.data.find("tokenizers/punkt_tab")
except LookupError:
nltk.download("punkt_tab")
def chap2text_epub(chap, item_id=None, toc=None):
"""
Extract chapter title and paragraphs from an EPUB chapter.
Args:
chap: The chapter content (HTML).
item_id: The ID of the item in the EPUB spine (for fallback naming).
toc: The EPUB's table of contents (for fallback title extraction).
Returns:
tuple: (chapter_title_text, paragraphs)
"""
blacklist = [
"[document]",
"noscript",
"header",
"html",
"meta",
"head",
"input",
"script",
]
paragraphs = []
soup = BeautifulSoup(chap, "html.parser")
# Step 1: Try to find chapter title in heading tags (<h1>, <h2>, <h3>)
heading_tags = ['h1', 'h2', 'h3']
chapter_title_text = None
for tag in heading_tags:
heading = soup.find(tag)
if heading and heading.text.strip():
chapter_title_text = heading.text.strip()
print(f"Found title in <{tag}>: '{chapter_title_text}'")
break
# Step 2: If no heading found, try elements with common class names
if not chapter_title_text:
common_classes = ['chapter', 'chapter-title', 'title', 'heading']
for class_name in common_classes:
element = soup.find(class_=class_name)
if element and element.text.strip():
chapter_title_text = element.text.strip()
print(f"Found title in class '{class_name}': '{chapter_title_text}'")
break
# Step 3: Fallback to TOC if provided
if not chapter_title_text and toc and item_id:
for toc_item in toc:
if toc_item.href.split('#')[0] == item_id:
chapter_title_text = toc_item.title
print(f"Found title in TOC for item '{item_id}': '{chapter_title_text}'")
break
# Step 4: Fallback to item ID or generic name
if not chapter_title_text:
chapter_title_text = item_id.replace('.xhtml', '').replace('_', ' ').title() if item_id else None
print(f"No title found, using fallback: '{chapter_title_text}'")
# Remove footnotes (links with only numbers)
for a in soup.findAll("a", href=True):
if not any(char.isalpha() for char in a.text):
a.extract()
# Remove superscript numbers (e.g., footnote markers)
for sup in soup.findAll("sup"):
if sup.text.isdigit():
sup.extract()
# Extract paragraphs
chapter_paragraphs = soup.find_all("p")
if not chapter_paragraphs:
print(f"No <p> tags found in '{chapter_title_text or item_id}'. Trying <div>.")
chapter_paragraphs = soup.find_all("div")
for p in chapter_paragraphs:
paragraph_text = "".join(p.strings).strip()
if paragraph_text:
paragraphs.append(paragraph_text)
return chapter_title_text, paragraphs
def get_epub_cover(epub_path):
try:
with zipfile.ZipFile(epub_path) as z:
t = etree.fromstring(z.read("META-INF/container.xml"))
rootfile_path = t.xpath("/u:container/u:rootfiles/u:rootfile",
namespaces=namespaces)[0].get("full-path")
t = etree.fromstring(z.read(rootfile_path))
cover_meta = t.xpath("//opf:metadata/opf:meta[@name='cover']",
namespaces=namespaces)
if not cover_meta:
print("No cover image found.")
return None
cover_id = cover_meta[0].get("content")
cover_item = t.xpath("//opf:manifest/opf:item[@id='" + cover_id + "']",
namespaces=namespaces)
if not cover_item:
print("No cover image found.")
return None
cover_href = cover_item[0].get("href")
cover_path = os.path.join(os.path.dirname(rootfile_path), cover_href)
if os.name == 'nt' and '\\' in cover_path:
cover_path = cover_path.replace("\\", "/")
return z.open(cover_path)
except FileNotFoundError:
print(f"Could not get cover image of {epub_path}")
def export(book, sourcefile):
book_contents = []
cover_image = get_epub_cover(sourcefile)
image_path = None
if cover_image is not None:
image = Image.open(cover_image)
image_filename = sourcefile.replace(".epub", ".png")
image_path = os.path.join(image_filename)
image.save(image_path)
print(f"Cover image saved to {image_path}")
# Get the table of contents
toc = book.get_toc() if hasattr(book, 'get_toc') else []
spine_ids = [spine_tuple[0] for spine_tuple in book.spine if spine_tuple[1] == 'yes']
items = {item.get_id(): item for item in book.get_items() if item.get_type() == ebooklib.ITEM_DOCUMENT}
for id in spine_ids:
item = items.get(id)
if item is None:
continue
# Pass item_id and toc to chap2text_epub
chapter_title, chapter_paragraphs = chap2text_epub(item.get_content(), item_id=id, toc=toc)
book_contents.append({"title": chapter_title, "paragraphs": chapter_paragraphs})
outfile = sourcefile.replace(".epub", ".txt")
check_for_file(outfile)
print(f"Exporting {sourcefile} to {outfile}")
author = book.get_metadata("DC", "creator")[0][0]
booktitle = book.get_metadata("DC", "title")[0][0]
with open(outfile, "w", encoding='utf-8') as file:
file.write(f"Title: {booktitle}\n")
file.write(f"Author: {author}\n\n")
file.write(f"# Title\n")
file.write(f"{booktitle}, by {author}\n\n")
for i, chapter in enumerate(book_contents, start=1):
if not chapter["paragraphs"] or chapter["paragraphs"] == ['']:
continue
else:
# Use chapter title if available, otherwise fallback to "Part {i}"
title = chapter["title"] if chapter["title"] else f"Part {i}"
file.write(f"# {title}\n\n")
for paragraph in chapter["paragraphs"]:
clean = re.sub(r'[\s\n]+', ' ', paragraph)
clean = re.sub(r'[“”]', '"', clean) # Curly double quotes to standard double quotes
clean = re.sub(r'[‘’]', "'", clean) # Curly single quotes to standard single quotes
clean = re.sub(r'--', ', ', clean)
file.write(f"{clean}\n\n")
return book_contents
def get_book(sourcefile):
book_contents = []
book_title = sourcefile
book_author = "Unknown"
chapter_titles = []
with open(sourcefile, "r", encoding="utf-8") as file:
current_chapter = {"title": "blank", "paragraphs": []}
initialized_first_chapter = False
lines_skipped = 0
for line in file:
if lines_skipped < 2 and (line.startswith("Title") or line.startswith("Author")):
lines_skipped += 1
if line.startswith('Title: '):
book_title = line.replace('Title: ', '').strip()
elif line.startswith('Author: '):
book_author = line.replace('Author: ', '').strip()
continue
line = line.strip()
if line.startswith("#"):
if current_chapter["paragraphs"] or not initialized_first_chapter:
if initialized_first_chapter:
book_contents.append(current_chapter)
current_chapter = {"title": None, "paragraphs": []}
initialized_first_chapter = True
chapter_title = line[1:].strip()
if any(c.isalnum() for c in chapter_title):
current_chapter["title"] = chapter_title
chapter_titles.append(current_chapter["title"])
else:
current_chapter["title"] = "blank"
chapter_titles.append("blank")
elif line:
if not initialized_first_chapter:
chapter_titles.append("blank")
initialized_first_chapter = True
if any(char.isalnum() for char in line):
sentences = sent_tokenize(line)
cleaned_sentences = [s for s in sentences if any(char.isalnum() for char in s)]
line = ' '.join(cleaned_sentences)
current_chapter["paragraphs"].append(line)
# Append the last chapter if it contains any paragraphs.
if current_chapter["paragraphs"]:
book_contents.append(current_chapter)
return book_contents, book_title, book_author, chapter_titles
def sort_key(s):
# extract number from the string
return int(re.findall(r'\d+', s)[0])
def check_for_file(filename):
if os.path.isfile(filename):
print(f"The file '{filename}' already exists.")
overwrite = input("Do you want to overwrite the file? (y/n): ")
if overwrite.lower() != 'y':
print("Exiting without overwriting the file.")
sys.exit()
else:
os.remove(filename)
def append_silence(tempfile, duration=1200):
audio = AudioSegment.from_file(tempfile)
# Create a silence segment
silence = AudioSegment.silent(duration)
# Append the silence segment to the audio
combined = audio + silence
# Save the combined audio back to file
combined.export(tempfile, format="flac")
def break_long_sentence(sentence, max_length=200):
# Split sentence based on commas
comma_segments = sentence.split(',')
segments = []
current_segment = ""
for segment in comma_segments:
# Check if adding the next segment exceeds max_length
temp_segment = current_segment + ("," if current_segment else "") + segment
if len(temp_segment) > max_length:
# Add the current segment to the list and reset it
if current_segment:
segments.append(current_segment)
# Start a new segment with the current part
current_segment = segment.strip()
else:
# Continue building the current segment
current_segment = temp_segment.strip()
# Don't forget to add the last segment if it exists
if current_segment:
segments.append(current_segment)
return segments
def process_large_text(line):
# Tokenize the text into sentences
sentences = sent_tokenize(line)
# Initialize a list to store processed sentences
results = []
i = 0
while i < len(sentences):
sentence = sentences[i]
word_count = len(sentence.split())
# Combine with the next sentence if this one has fewer than 8 words
if word_count < 8 and i + 1 < len(sentences):
# Combine the current and next sentence
sentence = sentence + ' ' + sentences[i + 1]
i += 1 # Skip the next sentence since it's already combined
if len(sentence) > 500:
# Break the long sentences into smaller parts using commas
results.extend(break_long_sentence(sentence, max_length=350))
else:
results.append(sentence)
i += 1 # Move to the next sentence
# Before returning, combine last elements if they are too short
if results and len(results[-1].split()) < 8:
if len(results) > 1:
# Combine the last two sentences if they are both short
results[-2] += ' ' + results[-1]
results.pop()
return results
def conditional_sentence_case(sent):
# Split the sentence into words
words = sent.split()
length = len(words)
# Iterate through words to check for three consecutive uppercase words
for i in range(length - 2):
if words[i].isupper() and words[i+1].isupper() and words[i+2].isupper():
# Convert the entire sentence to lowercase and capitalize the first letter
sent = ' '.join(words).lower().capitalize()
break # No need to continue checking once a match is found
return sent
def preprocess_book(book_path):
ensure_punkt()
book = epub.read_epub(book_path)
export(book, book_path)
+1 -1
View File
@@ -13,7 +13,7 @@ import zipfile
from pathlib import Path
from typing import Optional
from epub_tts.vendor_utils import get_repo_path
from epub_tts.utils import get_repo_path
def get_project_root() -> Path:
+1 -1
View File
@@ -34,7 +34,7 @@ import subprocess
from pathlib import Path
from typing import Optional, Callable
from .vendor_utils import build_run_command, ensure_venv
from .utils import build_run_command, ensure_venv
from . import log, PathLike
class GenericTTSBackend:
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html)
"""Backend for the kokoro engine.
No vendored repository needed, kokoro is a pure Python package that can be installed via pip.
"""
# stdlib modules
import subprocess
import os
import sys
# Automatically enable MPS fallback on Apple Silicon macOS
if sys.platform == 'darwin':
os.environ['PYTORCH_ENABLE_MPS_FALLBACK'] = '1'
# pip installed packages
import numpy as np
import soundfile
import torch
from tqdm import tqdm
from kokoro import KPipeline
from ebooklib import epub
import soundfile as sf
from mutagen import mp4
from pydub import AudioSegment
from mutagen import mp4
# Local imports
from .tts_generic import GenericTTSBackend
from . import log, PathLike
from .preprocess import preprocess_book
class KokoroBackend(GenericTTSBackend):
"""Generic kokoro backend wrapper.
Parameters:
repo: repository name (one of the keys in _CMD_MAP)
backend_cmd: explicit command/executable to use (overrides repo mapping)
language: optional language code
voice: optional voice name
The GenericTTSBackend stores the default command name in self.backend_cmd,
but the vendored adapters may still run the package in their own venv if
the console script is not present.
"""
INTERMEDIATE_TXT = True
INTERMEDIATE_CALL = GenericTTSBackend._replace_map
DEFAULT_SPEAKER = "am_liam" # ["af_heart", "am_michael", "am_liam"]
def run(self,
input_source: PathLike,
output_dest: PathLike,
**kwargs
) -> subprocess.CompletedProcess:
command = self._build_command(input_source, output_dest)
log(command)
# # PREPROCESSING STEP: If INTERMEDIATE_TXT is True, run the command to generate intermediate text first
# if not input_source.endswith(".txt"):
# completed = subprocess.run(
# self._build_command(input_source, output_dest),
# cwd=str(self.CWD),
# env=self.ENV,
# check=True)
# if self.INTERMEDIATE_TXT:
# txt_file = self._normalize_path(input_source).with_suffix(".txt")
# if txt_file.exists():
# if (self.INTERMEDIATE_CALL is not None and
# kwargs.get('replace_map', None) is not None):
# self.INTERMEDIATE_CALL(
# txt_file,
# txt_file.with_stem(txt_file.stem + "_replaced"), kwargs.get('replace_map', {}))
# txt_file = txt_file.with_stem(txt_file.stem + "_replaced")
# completed = subprocess.run(
# self._build_command(txt_file, output_dest),
# cwd=str(self.CWD),
# env=self.ENV,
# check=True
# )
# If we get an epub, export that to txt file
if input_source.endswith(".epub"):
book = preprocess_book(input_source)
# Check for GPU
if torch.cuda.is_available():
print('Nvidia GPU available. Setting as default device.')
torch.set_default_device('cuda')
elif torch.xpu.is_available():
print('Intel XPU (GPU) available. Setting as default device.')
torch.set_default_device('xpu')
elif torch.backends.mps.is_available():
print('Apple MPS GPU available. Setting as default device.')
torch.set_default_device('mps')
elif torch.backends.rocm.is_available():
print('AMD ROCm GPU available. Setting as default device.')
torch.set_default_device('rocm')
elif torch.is_vulkan_available():
print('Vulkan GPU available. Setting as default device.')
torch.set_default_device('vulkan')
else:
print('No GPU available. Using CPU.')
torch.set_default_device('cpu')
book_contents, book_title, book_author, chapter_titles = get_book(args.sourcefile)
files = read_book(book_contents, args.speaker, args.paragraphpause, args.speed, args.notitles)
generate_metadata(files, book_author, book_title, chapter_titles)
m4bfilename = make_m4b(files, args.sourcefile, args.speaker)
add_cover(args.cover, m4bfilename)
def kokoro_read(paragraph, speaker, filename, pipeline, speed):
audio_segments = []
sentences = process_large_text(paragraph)
for sent in sentences:
sent = conditional_sentence_case(sent.strip())
for gs, ps, audio in pipeline(sent, voice=speaker, speed=speed, split_pattern=r'\n\n\n'):
audio_segments.append(audio)
final_audio = np.concatenate(audio_segments)
soundfile.write(filename, final_audio, 24000)
def read_book(book_contents, speaker, paragraphpause, speed, notitles):
current_device_name = torch.get_default_device() if torch.get_default_device() else 'cpu'
current_device = torch.device(current_device_name)
print(f"Attempting to use device: {current_device}")
pipeline = KPipeline(lang_code=speaker[0])
# Explicitly move the model to the current default device (e.g., 'xpu')
if hasattr(pipeline, 'model') and pipeline.model is not None:
try:
pipeline.model.to(current_device)
print(f"Kokoro model explicitly moved to {current_device}")
except Exception as e:
print(f"Error moving Kokoro model to {current_device}: {e}")
else:
print("Warning: KPipeline does not have a 'model' attribute or model is None.")
segments = []
for i, chapter in enumerate(book_contents, start=1):
files = []
partname = f"part{i}.flac"
print(f"\n\n")
if os.path.isfile(partname):
print(f"{partname} exists, skipping to next chapter")
segments.append(partname)
else:
print(f"Chapter: {chapter['title']}\n")
print(f"Section name: \"{chapter['title']}\"")
if chapter["title"] == "":
chapter["title"] = "blank"
if chapter["title"] != "Title" and notitles != True:
title_temp = "title.flac"
if not os.path.isfile(title_temp):
kokoro_read(chapter['title'] + ".", speaker, "title_temp.wav", pipeline, speed)
append_silence("title_temp.wav", paragraphpause)
# Convert to flac
audio = AudioSegment.from_file("title_temp.wav")
audio.export(title_temp, format="flac")
os.remove("title_temp.wav")
files.append(title_temp)
for pindex, paragraph in enumerate(
tqdm(chapter["paragraphs"], desc=f"Generating audio files: ",unit='pg')
):
ptemp = f"pgraphs{pindex}.flac"
if os.path.isfile(ptemp):
print(f"{ptemp} exists, skipping to next paragraph")
else:
#sentences = sent_tokenize(paragraph)
filenames = ["sntnc1.wav"]
kokoro_read(paragraph, speaker, "sntnc1.wav", pipeline, speed)
append_silence("sntnc1.wav", paragraphpause)
# combine sentences in paragraph
sorted_files = sorted(filenames, key=sort_key)
if os.path.exists("sntnc0.wav"):
sorted_files.insert(0, "sntnc0.wav")
combined = AudioSegment.empty()
for file in sorted_files:
combined += AudioSegment.from_file(file)
combined.export(ptemp, format="flac")
for file in sorted_files:
os.remove(file)
files.append(ptemp)
# combine paragraphs into chapter
append_silence(files[-1], 2000)
combined = AudioSegment.empty()
for file in files:
combined += AudioSegment.from_file(file)
combined.export(partname, format="flac")
for file in files:
os.remove(file)
segments.append(partname)
return segments
@@ -6,6 +6,7 @@
"""Utilities for running vendored EPUB->TTS repositories in isolated venvs."""
import subprocess
import shutil
import sys
import venv
from pathlib import Path
@@ -71,3 +72,21 @@ def build_run_command(
# else str(venv_path / "bin" / backend_cmd)
# )
def check_env(self, which=None):
if not which:
return None
if isinstance(which, str):
if " " in which:
which = which.split()
which = [which]
for bin in which:
p = shutil.which(bin)
if not p:
log(f"{bin} is either NOT installed or "
"NOT in your system PATH.")
return False
else:
log(f"{bin} found at: {p}")
return True