3925 lines
182 KiB
Python
3925 lines
182 KiB
Python
# NOTE!!NOTE!!!NOTE!!NOTE!!!NOTE!!NOTE!!!NOTE!!NOTE!!!
|
|
# THE WORD "CHAPTER" IN THE CODE DOES NOT MEAN
|
|
# IT'S THE REAL CHAPTER OF THE EBOOK SINCE NO STANDARDS
|
|
# ARE DEFINING A CHAPTER ON .EPUB FORMAT. THE WORD "BLOCK"
|
|
# IS USED TO PRINT IT OUT TO THE TERMINAL, AND "CHAPTER" TO THE CODE
|
|
# WHICH IS LESS GENERIC FOR THE DEVELOPERS
|
|
|
|
import argparse, asyncio, csv, fnmatch, sqlite3, hashlib, io, json, math, os, pytesseract, gc
|
|
import random, shutil, subprocess, sys, tempfile, threading, time, uvicorn, copy
|
|
import traceback, socket, unicodedata, urllib.request, uuid, zipfile, fitz, multiprocessing
|
|
import ebooklib, psutil, requests, stanza, importlib, queue, pykakasi
|
|
import regex as re, gradio as gr
|
|
|
|
from typing import Any, Generator, Dict
|
|
from PIL import Image, ImageSequence
|
|
from tqdm import tqdm
|
|
from bs4 import BeautifulSoup, NavigableString, Tag
|
|
from collections import Counter
|
|
from collections.abc import Mapping, MutableMapping
|
|
from datetime import datetime
|
|
from ebooklib import epub
|
|
from ebooklib.epub import EpubBook
|
|
from ebooklib.epub import EpubHtml
|
|
from glob import glob
|
|
from iso639 import Lang
|
|
from markdown import markdown
|
|
from multiprocessing import Pool, cpu_count
|
|
from multiprocessing import Manager, Event
|
|
from multiprocessing.managers import DictProxy, ListProxy, SyncManager
|
|
from stanza.pipeline.core import Pipeline, DownloadMethod
|
|
from num2words2 import num2words
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
from pydub import AudioSegment
|
|
from pydub.utils import mediainfo
|
|
from queue import Queue, Empty
|
|
from types import MappingProxyType
|
|
from langdetect import detect
|
|
from unidecode import unidecode
|
|
from phonemizer import phonemize
|
|
from pypinyin import pinyin, Style
|
|
|
|
from lib.classes.subprocess_pipe import SubprocessPipe
|
|
from lib.classes.vram_detector import VRAMDetector
|
|
from lib.classes.voice_extractor import VoiceExtractor
|
|
from lib.classes.non_text_filter import NonTextFilter
|
|
#from lib.classes.redirect_console import RedirectConsole
|
|
from lib.classes.argos_translator import ArgosTranslator
|
|
from lib.classes.tts_manager import TTSManager
|
|
from lib.classes.tts_engines.common.audio import get_audiolist_duration, get_audio_duration
|
|
from lib.classes.tts_engines.common.utils import build_vtt_file
|
|
|
|
from lib import *
|
|
|
|
#import logging
|
|
#logging.basicConfig(
|
|
# level=logging.INFO, # DEBUG for more verbosity
|
|
# format="%(asctime)s [%(levelname)s] %(message)s"
|
|
#)
|
|
|
|
context = None
|
|
context_tracker = None
|
|
active_sessions = None
|
|
progress_bar = None
|
|
|
|
status_tags = {
|
|
"OVERRIDE": "override",
|
|
"DELETION": "deletion",
|
|
"READY": "ready",
|
|
"EDIT": "edit",
|
|
"SKIP": "skip",
|
|
"SWITCH": "switch",
|
|
"CONVERTING": "converting",
|
|
"END": "end",
|
|
"DISCONNECTED": "disconnected"
|
|
}
|
|
|
|
ebook_modes = {
|
|
"SINGLE": "single",
|
|
"DIRECTORY": "directory",
|
|
"TEXT": "text"
|
|
}
|
|
|
|
save_session_keys_except = [
|
|
'blocks_orig',
|
|
'blocks_saved',
|
|
'blocks_current'
|
|
]
|
|
|
|
file_prefixes = {
|
|
"clone": "__",
|
|
"saved": "__saved_",
|
|
'current': "__current_"
|
|
}
|
|
|
|
########### Classes
|
|
|
|
class DependencyError(Exception):
|
|
def __init__(self, message:str|None):
|
|
super().__init__(message)
|
|
print(message)
|
|
# Automatically handle the exception when it's raised
|
|
self.handle_exception()
|
|
|
|
def handle_exception(self)->None:
|
|
# Print the full traceback of the exception
|
|
traceback.print_exc()
|
|
# Print the exception message
|
|
error = f'Caught DependencyError: {self}'
|
|
print(error)
|
|
|
|
class SessionTracker:
|
|
def __init__(self):
|
|
self.lock = threading.Lock()
|
|
#self.blocks_autosave = AppAutosave()
|
|
#self.blocks_autosave.start()
|
|
|
|
def start_session(self, session_id:str)->bool:
|
|
with self.lock:
|
|
if session_id not in context.sessions:
|
|
return False
|
|
session = context.get_session(session_id)
|
|
session['status'] = status_tags['READY']
|
|
return True
|
|
|
|
def end_session(self, session_id:str, socket_hash:str)->None:
|
|
#self.blocks_autosave.unregister(session_id)
|
|
active_sessions.discard(socket_hash)
|
|
with self.lock:
|
|
context.sessions.pop(session_id, None)
|
|
|
|
class SessionContext:
|
|
def __init__(self):
|
|
self.manager:Manager = Manager()
|
|
self.sessions:DictProxy[str, DictProxy[str, Any]] = self.manager.dict()
|
|
self.cancellation_events = {}
|
|
|
|
def _recursive_proxy(self, data:Any, manager:SyncManager|None)->Any:
|
|
if manager is None:
|
|
manager = self.manager
|
|
if isinstance(data, dict):
|
|
proxy_dict = manager.dict()
|
|
for key, value in data.items():
|
|
proxy_dict[key] = self._recursive_proxy(value, manager)
|
|
return proxy_dict
|
|
elif isinstance(data, list):
|
|
proxy_list = manager.list()
|
|
for item in data:
|
|
proxy_list.append(self._recursive_proxy(item, manager))
|
|
return proxy_list
|
|
elif isinstance(data, (str, int, float, bool, type(None))):
|
|
return data
|
|
else:
|
|
error = f'Unsupported data type: {type(data)}'
|
|
print(error)
|
|
return None
|
|
|
|
def set_session(self, session_id:str)->Any:
|
|
self.sessions[session_id] = self._recursive_proxy({
|
|
####### Global settings
|
|
"id": session_id,
|
|
"script_mode": NATIVE,
|
|
"tab_id": None,
|
|
"socket_hash": None,
|
|
"session_dir": None,
|
|
"is_gui_process": False,
|
|
"free_vram_gb": 0,
|
|
"status": None,
|
|
"ticker": 0,
|
|
"cancellation_requested": False,
|
|
"ebook_mode": ebook_modes['SINGLE'],
|
|
"blocks_preview": False,
|
|
"device": default_device,
|
|
"tts_engine": default_tts_engine,
|
|
"fine_tuned": default_fine_tuned,
|
|
"model_cache": None,
|
|
"model_zs_cache": None,
|
|
"stanza_cache": None,
|
|
"system": None,
|
|
"client": None,
|
|
"language": default_language_code,
|
|
"language_iso1": None,
|
|
"translate_enabled": False,
|
|
"translate": None,
|
|
"translate_iso1": None,
|
|
"voice": None,
|
|
"voice_dir": None,
|
|
"voice_map": {},
|
|
"ebook_selected": None,
|
|
"custom_model": None,
|
|
"custom_model_dir": None,
|
|
"output_dir": None,
|
|
"output_format": default_output_format,
|
|
"output_channel": default_output_channel,
|
|
"output_split": default_output_split,
|
|
"output_split_hours": default_output_split_hours,
|
|
####### Xtts settings
|
|
"xtts_temperature": default_engine_settings[TTS_ENGINES['XTTS']]['temperature'],
|
|
#"xtts_codec_temperature": default_engine_settings[TTS_ENGINES['XTTS']]['codec_temperature'],
|
|
"xtts_length_penalty": default_engine_settings[TTS_ENGINES['XTTS']]['length_penalty'],
|
|
"xtts_num_beams": default_engine_settings[TTS_ENGINES['XTTS']]['num_beams'],
|
|
"xtts_repetition_penalty": default_engine_settings[TTS_ENGINES['XTTS']]['repetition_penalty'],
|
|
#"xtts_cvvp_weight": default_engine_settings[TTS_ENGINES['XTTS']]['cvvp_weight'],
|
|
"xtts_top_k": default_engine_settings[TTS_ENGINES['XTTS']]['top_k'],
|
|
"xtts_top_p": default_engine_settings[TTS_ENGINES['XTTS']]['top_p'],
|
|
"xtts_speed": default_engine_settings[TTS_ENGINES['XTTS']]['speed'],
|
|
#"xtts_gpt_cond_len": default_engine_settings[TTS_ENGINES['XTTS']]['gpt_cond_len'],
|
|
#"xtts_gpt_batch_size": default_engine_settings[TTS_ENGINES['XTTS']]['gpt_batch_size'],
|
|
"xtts_enable_text_splitting": default_engine_settings[TTS_ENGINES['XTTS']]['enable_text_splitting'],
|
|
####### Bark settings
|
|
"bark_text_temp": default_engine_settings[TTS_ENGINES['BARK']]['text_temp'],
|
|
"bark_waveform_temp": default_engine_settings[TTS_ENGINES['BARK']]['waveform_temp'],
|
|
####### Audiobook editor
|
|
"audiobook": None,
|
|
"audiobooks_dir": None,
|
|
####### Ebook conversion
|
|
"ebook": None,
|
|
"ebook_src": None,
|
|
"ebook_list": None,
|
|
"ebook_textarea": None,
|
|
"ebook_textarea_src": None,
|
|
"audiobook_overridden": None,
|
|
"process_dir": None,
|
|
"chapters_dir": None,
|
|
"sentences_dir": None,
|
|
"epub_path": None,
|
|
"final_name": None,
|
|
"filename_noext": None,
|
|
"cover": None,
|
|
"blocks_orig": {},
|
|
"blocks_saved": {},
|
|
"blocks_current": {},
|
|
"blocks_orig_json": None,
|
|
"blocks_saved_json": None,
|
|
"blocks_current_db": None,
|
|
"duration": 0,
|
|
"playback_time": 0,
|
|
"playback_volume": 0,
|
|
"metadata": {
|
|
"title": None,
|
|
"creator": None,
|
|
"contributor": None,
|
|
"language": None,
|
|
"identifier": None,
|
|
"publisher": None,
|
|
"date": None,
|
|
"description": None,
|
|
"subject": None,
|
|
"rights": None,
|
|
"format": None,
|
|
"type": None,
|
|
"coverage": None,
|
|
"relation": None,
|
|
"Source": None,
|
|
"Modified": None,
|
|
}
|
|
}, manager=self.manager)
|
|
return self.sessions[session_id]
|
|
|
|
def get_session(self, session_id:str)->Any:
|
|
if session_id in self.sessions:
|
|
return self.sessions[session_id]
|
|
return {}
|
|
|
|
def find_id_by_hash(self, socket_hash:str)->str|None:
|
|
for session_id, session in list(self.sessions.items()):
|
|
if socket_hash in session:
|
|
return session_id
|
|
return None
|
|
|
|
class JSONDictProxyEncoder(json.JSONEncoder):
|
|
def default(self, o:Any)->Any:
|
|
if isinstance(o, DictProxy):
|
|
return dict(o)
|
|
elif isinstance(o, ListProxy):
|
|
return list(o)
|
|
return super().default(o)
|
|
|
|
"""
|
|
class AppAutosave:
|
|
def __init__(self, interval:float=15.0):
|
|
self._interval = interval
|
|
self._sessions: set[str] = set()
|
|
self._lock = threading.Lock()
|
|
self._started = False
|
|
|
|
def start(self)->None:
|
|
if self._started:
|
|
return
|
|
self._started = True
|
|
t = threading.Thread(target=self._timer, daemon=True)
|
|
t.start()
|
|
|
|
def register(self, session_id:str)->None:
|
|
with self._lock:
|
|
self._sessions.add(session_id)
|
|
|
|
def unregister(self, session_id:str)->None:
|
|
with self._lock:
|
|
self._sessions.discard(session_id)
|
|
|
|
def _timer(self)->None:
|
|
while True:
|
|
time.sleep(self._interval)
|
|
with self._lock:
|
|
session_ids = set(self._sessions)
|
|
for session_id in session_ids:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not session or not session.get('id', False):
|
|
with self._lock:
|
|
self._sessions.discard(session_id)
|
|
continue
|
|
except Exception as e:
|
|
logger.error(f'AppAutosave._timer({session_id}): {e}!')
|
|
"""
|
|
|
|
############# End classes
|
|
|
|
def prepare_dirs(session_id:str)->bool:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
resume = False
|
|
os.makedirs(os.path.join(models_dir,'tts'), exist_ok=True)
|
|
os.makedirs(session['session_dir'], exist_ok=True)
|
|
os.makedirs(session['process_dir'], exist_ok=True)
|
|
os.makedirs(session['custom_model_dir'], exist_ok=True)
|
|
os.makedirs(session['voice_dir'], exist_ok=True)
|
|
os.makedirs(session['audiobooks_dir'], exist_ok=True)
|
|
os.makedirs(session['chapters_dir'], exist_ok=True)
|
|
os.makedirs(session['sentences_dir'], exist_ok=True)
|
|
return True
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
return False
|
|
|
|
def check_programs(prog_name:str, command:str, options:str)->bool:
|
|
try:
|
|
subprocess.run(
|
|
[command, options],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
check=True,
|
|
text=True,
|
|
encoding='utf-8'
|
|
)
|
|
return True
|
|
except FileNotFoundError:
|
|
e = f'''********** Error: {prog_name} is not installed! if your OS calibre package version
|
|
is not compatible you still can run ebook2audiobook.sh (linux/mac) or ebook2audiobook.cmd (windows) **********'''
|
|
DependencyError(e)
|
|
except subprocess.CalledProcessError:
|
|
e = f'Error: There was an issue running {prog_name}.'
|
|
DependencyError(e)
|
|
return False
|
|
|
|
def analyze_uploaded_file(zip_path:str, required_files:list[str])->bool:
|
|
try:
|
|
if not os.path.exists(zip_path):
|
|
error = f'The file does not exist: {os.path.basename(zip_path)}'
|
|
print(error)
|
|
return False
|
|
files_in_zip = {}
|
|
empty_files = set()
|
|
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
for file_info in zf.infolist():
|
|
file_name = file_info.filename
|
|
if file_info.is_dir():
|
|
continue
|
|
base_name = os.path.basename(file_name)
|
|
files_in_zip[base_name] = file_info.file_size
|
|
if file_info.file_size == 0:
|
|
empty_files.add(base_name)
|
|
required_files = [file for file in required_files]
|
|
missing_files = [f for f in required_files if f not in files_in_zip]
|
|
required_empty_files = [f for f in required_files if f in empty_files]
|
|
if missing_files:
|
|
msg = f'Missing required files: {missing_files}'
|
|
print(msg)
|
|
if required_empty_files:
|
|
msg = f'Required files with 0 KB: {required_empty_files}'
|
|
print(msg)
|
|
return not missing_files and not required_empty_files
|
|
except zipfile.BadZipFile:
|
|
error = 'The file is not a valid ZIP archive.'
|
|
print(error)
|
|
return False
|
|
except Exception as e:
|
|
error = f'An error occurred: {e}'
|
|
print(error)
|
|
return False
|
|
|
|
def extract_custom_model(session_id)->str|None:
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
file_src = session['custom_model']
|
|
required_files = default_engine_settings[session['tts_engine']]['files']
|
|
model_path = None
|
|
model_name = re.sub('.zip', '', os.path.basename(file_src), flags=re.IGNORECASE)
|
|
model_name = get_sanitized(model_name)
|
|
try:
|
|
with zipfile.ZipFile(file_src, 'r') as zip_ref:
|
|
files = zip_ref.namelist()
|
|
files_length = len(files)
|
|
tts_dir = session['tts_engine']
|
|
model_path = os.path.join(session['custom_model_dir'], tts_dir, model_name)
|
|
os.makedirs(model_path, exist_ok=True)
|
|
msg = f'Extracting files to {model_path}…'
|
|
with tqdm(total=files_length, unit='files') as t:
|
|
for f in files:
|
|
base_f = os.path.basename(f)
|
|
if base_f in required_files:
|
|
out_path = os.path.join(model_path, base_f)
|
|
with zip_ref.open(f) as src, open(out_path, 'wb') as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
t.update(1)
|
|
if session['is_gui_process']:
|
|
progress_bar((t.n + 1) / files_length, desc=msg)
|
|
if model_path is not None:
|
|
msg = f'Normalizing ref.wav…'
|
|
print(msg)
|
|
voice_ref = os.path.join(model_path, 'ref.wav')
|
|
voice_name = model_name
|
|
final_voice_file = os.path.join(model_path, f'{voice_name}.wav')
|
|
extractor = VoiceExtractor(session, voice_ref, voice_name, final_voice_file)
|
|
status, msg = extractor.extract_voice()
|
|
if status:
|
|
session['voice'] = final_voice_file
|
|
if os.path.exists(file_src):
|
|
os.remove(file_src)
|
|
if os.path.exists(voice_ref):
|
|
os.remove(voice_ref)
|
|
return model_path
|
|
else:
|
|
error = f'extract_custom_model() VoiceExtractor.extract_voice() error! {msg}'
|
|
print(error)
|
|
else:
|
|
error = f'An error occurred when unzip {file_src}'
|
|
print(error)
|
|
except asyncio.exceptions.CancelledError as e:
|
|
DependencyError(e)
|
|
error = f'extract_custom_model asyncio.exceptions.CancelledError: {e}'
|
|
print(error)
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
error = f'extract_custom_model Exception: {e}'
|
|
print(error)
|
|
if session['is_gui_process']:
|
|
if os.path.exists(file_src):
|
|
os.remove(file_src)
|
|
session['custom_model'] = None
|
|
return None
|
|
|
|
def hash_proxy_dict(proxy_dict:Any)->str:
|
|
try:
|
|
data = {k: v for k, v in dict(proxy_dict).items() if k not in save_session_keys_except}
|
|
except Exception:
|
|
data = {}
|
|
data_str = json.dumps(data, default=str, sort_keys=True)
|
|
return hashlib.md5(data_str.encode('utf-8')).hexdigest()
|
|
|
|
def compare_checksums(session_id:str)->tuple[bool, str|None]:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
hash_algorithm:str = 'sha256'
|
|
checksum_path = os.path.join(session['process_dir'], 'checksum')
|
|
hash_func = hashlib.new(hash_algorithm)
|
|
with open(session['ebook'], 'rb') as f:
|
|
while chunk := f.read(8192):
|
|
hash_func.update(chunk)
|
|
new_checksum = hash_func.hexdigest()
|
|
if not os.path.exists(checksum_path):
|
|
with open(checksum_path, 'w', encoding='utf-8') as f:
|
|
f.write(new_checksum)
|
|
return False, None
|
|
else:
|
|
with open(checksum_path, 'r', encoding='utf-8') as f:
|
|
saved_checksum = f.read().strip()
|
|
if saved_checksum == new_checksum:
|
|
return True, None
|
|
else:
|
|
with open(checksum_path, 'w', encoding='utf-8') as f:
|
|
f.write(new_checksum)
|
|
return False, None
|
|
error = f'compare_checksums() error: session does not exist'
|
|
return False, error
|
|
except Exception as e:
|
|
error = f'compare_checksums() error: {e}'
|
|
return False, error
|
|
|
|
def compare_dict_keys(d1, d2):
|
|
if not isinstance(d1, Mapping) or not isinstance(d2, Mapping):
|
|
return d1 == d2
|
|
d1_keys = set(d1.keys())
|
|
d2_keys = set(d2.keys())
|
|
missing_in_d2 = d1_keys - d2_keys
|
|
missing_in_d1 = d2_keys - d1_keys
|
|
if missing_in_d2 or missing_in_d1:
|
|
return {
|
|
"missing_in_d2": missing_in_d2,
|
|
"missing_in_d1": missing_in_d1,
|
|
}
|
|
for key in d1_keys.intersection(d2_keys):
|
|
nested_result = compare_keys(d1[key], d2[key])
|
|
if nested_result:
|
|
return {key: nested_result}
|
|
return None
|
|
|
|
def ocr2xhtml(img: Image.Image, lang:str)->tuple[str|bool, str|None]:
|
|
try:
|
|
debug = True
|
|
try:
|
|
data = pytesseract.image_to_data(img, lang=lang, output_type=pytesseract.Output.DATAFRAME)
|
|
# Handle silent OCR failures (empty or None result)
|
|
if data is None or data.empty:
|
|
error = f'Tesseract returned empty OCR data for language "{lang}".'
|
|
return False, error
|
|
except (pytesseract.TesseractError, Exception) as e:
|
|
print(f'The OCR {lang} trained model must be downloaded.')
|
|
try:
|
|
tessdata_dir = os.environ['TESSDATA_PREFIX']
|
|
os.makedirs(tessdata_dir, exist_ok=True)
|
|
url = f'https://github.com/tesseract-ocr/tessdata_best/raw/main/{lang}.traineddata'
|
|
dest_path = os.path.join(tessdata_dir, f'{lang}.traineddata')
|
|
msg = f'Downloading {lang}.traineddata into {tessdata_dir}…'
|
|
print(msg)
|
|
response = requests.get(url, timeout=15)
|
|
if response.status_code == 200:
|
|
with open(dest_path, 'wb') as f:
|
|
f.write(response.content)
|
|
msg = f'Downloaded and installed {lang}.traineddata successfully.'
|
|
print(msg)
|
|
data = pytesseract.image_to_data(img, lang=lang, output_type=pytesseract.Output.DATAFRAME)
|
|
if data is None or data.empty:
|
|
error = f'Tesseract returned empty OCR data even after downloading {lang}.traineddata.'
|
|
return False, error
|
|
else:
|
|
error = f'Failed to download traineddata for {lang} (HTTP {response.status_code})'
|
|
return False, error
|
|
except Exception as e:
|
|
error = f'Automatic download failed: {e}'
|
|
return False, error
|
|
data = data.dropna(subset=['text'])
|
|
lines = []
|
|
last_block = None
|
|
for _, row in data.iterrows():
|
|
text = row['text'].strip()
|
|
if not text:
|
|
continue
|
|
block = row['block_num']
|
|
if last_block is not None and block != last_block:
|
|
lines.append('') # blank line between blocks
|
|
lines.append(text)
|
|
last_block = block
|
|
joined = '\n'.join(lines)
|
|
raw_lines = [l.strip() for l in joined.split('\n')]
|
|
# Normalize line breaks
|
|
merged_lines = []
|
|
buffer = ''
|
|
for i, line in enumerate(raw_lines):
|
|
if not line:
|
|
if buffer:
|
|
merged_lines.append(buffer.strip())
|
|
buffer = ''
|
|
continue
|
|
if buffer and not buffer.endswith(('.', '?', '!', ':')) and not line[0].isupper():
|
|
buffer += ' ' + line
|
|
else:
|
|
if buffer:
|
|
merged_lines.append(buffer.strip())
|
|
buffer = line
|
|
if buffer:
|
|
merged_lines.append(buffer.strip())
|
|
# Detect heading-like lines
|
|
xhtml_parts = []
|
|
debug_dump = []
|
|
for i, p in enumerate(merged_lines):
|
|
is_heading = False
|
|
if p.isupper() and len(p.split()) <= 8:
|
|
is_heading = True
|
|
elif len(p.split()) <= 5 and p.istitle():
|
|
is_heading = True
|
|
elif (i == 0 or (i > 0 and merged_lines[i-1] == '')) and len(p.split()) <= 10:
|
|
is_heading = True
|
|
if is_heading:
|
|
xhtml_parts.append(f'<h2>{p}</h2>')
|
|
debug_dump.append(f'[H2] {p}')
|
|
else:
|
|
xhtml_parts.append(f'<p>{p}</p>')
|
|
debug_dump.append(f'[P ] {p}')
|
|
if debug:
|
|
print('=== OCR DEBUG OUTPUT ===')
|
|
for line in debug_dump:
|
|
print(line)
|
|
print('========================')
|
|
return '\n'.join(xhtml_parts), None
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
error = f'ocr2xhtml error: {e}'
|
|
return False, error
|
|
|
|
def create_db_blocks(db_path:str)->None:
|
|
os.makedirs(os.path.dirname(db_path) or '.', exist_ok=True)
|
|
with sqlite3.connect(db_path) as conn:
|
|
conn.execute('PRAGMA journal_mode=WAL')
|
|
conn.execute('PRAGMA synchronous=NORMAL')
|
|
conn.execute('PRAGMA foreign_keys=ON')
|
|
conn.executescript('''
|
|
CREATE TABLE IF NOT EXISTS stamp (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
page INTEGER,
|
|
block_resume INTEGER,
|
|
sentence_resume INTEGER,
|
|
voice TEXT,
|
|
tts_engine TEXT,
|
|
fine_tuned TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS blocks (
|
|
id TEXT PRIMARY KEY,
|
|
idx INTEGER NOT NULL,
|
|
expand INTEGER NOT NULL,
|
|
keep INTEGER NOT NULL,
|
|
text TEXT NOT NULL,
|
|
voice TEXT,
|
|
tts_engine TEXT,
|
|
fine_tuned TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sentences (
|
|
block_id TEXT NOT NULL,
|
|
idx INTEGER NOT NULL,
|
|
text TEXT NOT NULL,
|
|
PRIMARY KEY (block_id, idx),
|
|
FOREIGN KEY (block_id) REFERENCES blocks(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_blocks_idx ON blocks(idx);
|
|
INSERT OR IGNORE INTO stamp (id, page, block_resume, sentence_resume, voice, tts_engine, fine_tuned)
|
|
VALUES (1, 0, 0, 0, NULL, NULL, NULL);
|
|
''')
|
|
|
|
def load_db_blocks(db_path:str)->dict:
|
|
try:
|
|
if not os.path.exists(db_path):
|
|
return {}
|
|
with sqlite3.connect(db_path) as conn:
|
|
conn.execute('PRAGMA foreign_keys=ON')
|
|
stamp_row = conn.execute(
|
|
'SELECT page, block_resume, sentence_resume, voice, tts_engine, fine_tuned FROM stamp WHERE id=1'
|
|
).fetchone()
|
|
if stamp_row is None:
|
|
return {}
|
|
page, block_resume, sentence_resume, voice, tts_engine, fine_tuned = stamp_row
|
|
sentences_by_block = {}
|
|
for block_id, text in conn.execute('SELECT block_id, text FROM sentences ORDER BY block_id, idx'):
|
|
sentences_by_block.setdefault(block_id, []).append(text)
|
|
blocks = []
|
|
for row in conn.execute('SELECT id, expand, keep, text, voice, tts_engine, fine_tuned FROM blocks ORDER BY idx'):
|
|
bid, expand, keep, text, b_voice, b_tts_engine, b_fine_tuned = row
|
|
blocks.append({
|
|
'id': bid,
|
|
'expand': bool(expand),
|
|
'keep': bool(keep),
|
|
'text': text,
|
|
'voice': b_voice,
|
|
'tts_engine': b_tts_engine,
|
|
'fine_tuned': b_fine_tuned,
|
|
'sentences': sentences_by_block.get(bid, []),
|
|
})
|
|
return {
|
|
'page': page,
|
|
'block_resume': block_resume,
|
|
'sentence_resume': sentence_resume,
|
|
'voice': voice,
|
|
'tts_engine': tts_engine,
|
|
'fine_tuned': fine_tuned,
|
|
'blocks': blocks,
|
|
}
|
|
except Exception as e:
|
|
error = f'load_db_blocks() error: {e}'
|
|
print(error)
|
|
return {}
|
|
|
|
def save_db_stamp(session_id:str)->None:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not (session and session.get('id', False)):
|
|
return
|
|
data = session['blocks_current']
|
|
if not data:
|
|
return
|
|
db_path = session['blocks_current_db']
|
|
create_db_blocks(db_path)
|
|
with sqlite3.connect(db_path) as conn:
|
|
conn.execute(
|
|
'UPDATE stamp SET page=?, block_resume=?, sentence_resume=?, voice=?, tts_engine=?, fine_tuned=? WHERE id=1',
|
|
(
|
|
data.get('page'),
|
|
data.get('block_resume'),
|
|
data.get('sentence_resume'),
|
|
data.get('voice'),
|
|
data.get('tts_engine'),
|
|
data.get('fine_tuned'),
|
|
)
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
error = f'save_db_stamp() error: {e}'
|
|
print(error)
|
|
|
|
def save_db_blocks(session_id:str)->None:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not (session and session.get('id', False)):
|
|
return
|
|
data = session['blocks_current']
|
|
if not data:
|
|
return
|
|
db_path = session['blocks_current_db']
|
|
create_db_blocks(db_path)
|
|
with sqlite3.connect(db_path) as conn:
|
|
conn.execute('PRAGMA foreign_keys=ON')
|
|
conn.execute(
|
|
'UPDATE stamp SET page=?, block_resume=?, sentence_resume=?, voice=?, tts_engine=?, fine_tuned=? WHERE id=1',
|
|
(
|
|
data.get('page'),
|
|
data.get('block_resume'),
|
|
data.get('sentence_resume'),
|
|
data.get('voice'),
|
|
data.get('tts_engine'),
|
|
data.get('fine_tuned'),
|
|
)
|
|
)
|
|
new_blocks = data.get('blocks', [])
|
|
new_ids = {b['id'] for b in new_blocks}
|
|
existing_ids = {row[0] for row in conn.execute('SELECT id FROM blocks')}
|
|
removed = existing_ids - new_ids
|
|
if removed:
|
|
conn.executemany('DELETE FROM blocks WHERE id=?', [(rid,) for rid in removed])
|
|
for idx, block in enumerate(new_blocks):
|
|
block_id = block['id']
|
|
conn.execute(
|
|
'INSERT INTO blocks (id, idx, expand, keep, text, voice, tts_engine, fine_tuned) '
|
|
'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
|
|
'ON CONFLICT(id) DO UPDATE SET '
|
|
'idx=excluded.idx, expand=excluded.expand, keep=excluded.keep, text=excluded.text, '
|
|
'voice=excluded.voice, tts_engine=excluded.tts_engine, fine_tuned=excluded.fine_tuned',
|
|
(
|
|
block_id,
|
|
idx,
|
|
1 if block.get('expand') else 0,
|
|
1 if block.get('keep') else 0,
|
|
block.get('text', ''),
|
|
block.get('voice'),
|
|
block.get('tts_engine'),
|
|
block.get('fine_tuned'),
|
|
)
|
|
)
|
|
conn.execute('DELETE FROM sentences WHERE block_id=?', (block_id,))
|
|
sentences = block.get('sentences', [])
|
|
if sentences:
|
|
conn.executemany(
|
|
'INSERT INTO sentences (block_id, idx, text) VALUES (?, ?, ?)',
|
|
[(block_id, i, s) for i, s in enumerate(sentences)]
|
|
)
|
|
conn.commit()
|
|
except Exception as e:
|
|
error = f'save_db_blocks() error: {e}'
|
|
print(error)
|
|
|
|
def load_json_blocks(filepath:str)->dict:
|
|
try:
|
|
with open(filepath, "r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
error = f"load_json_blocks() error: {e}"
|
|
print(error)
|
|
return {}
|
|
|
|
def save_json_blocks(session_id:str, key:str)->None:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if (session and session.get('id', False)):
|
|
json_data = session[key]
|
|
json_path = session[f'{key}_json']
|
|
with open(json_path, 'w', encoding='utf-8') as f:
|
|
json.dump(json_data, f, ensure_ascii=False, indent=2)
|
|
except Exception as e:
|
|
print(f'save_json_blocks() error: {e}')
|
|
|
|
def sync_globals_to_blocks(session_id:str)->None:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not (session and session.get('id', False)):
|
|
return
|
|
blocks_current = session.get('blocks_current') or {}
|
|
anchor_voice = blocks_current.get('voice')
|
|
current_voice = session.get('voice')
|
|
if anchor_voice == current_voice:
|
|
return
|
|
changed = False
|
|
for block in blocks_current.get('blocks', []):
|
|
if block.get('voice') == anchor_voice:
|
|
block['voice'] = current_voice
|
|
changed = True
|
|
blocks_current['voice'] = current_voice
|
|
session['blocks_current'] = blocks_current
|
|
except Exception as e:
|
|
exception_alert(session_id, f'sync_globals_to_blocks(): {e}')
|
|
|
|
def normalize_epub_zip(session_id:str, file_input:str)->str|None:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not (session and session.get('id', False)):
|
|
return None
|
|
with zipfile.ZipFile(file_input, 'r') as zf:
|
|
names = [n for n in zf.namelist() if n and not n.endswith('/')]
|
|
epubs = [n for n in names if n.lower().endswith('.epub')]
|
|
if len(epubs) > 1:
|
|
msg = f'Unsupported ZIP ebook wrapper: expected one nested .epub file, found {len(epubs)}'
|
|
print(msg)
|
|
return None
|
|
nested_epub = epubs[0] if epubs else None
|
|
if nested_epub:
|
|
# case 1 - a real .epub FILE sits inside the zip, just extract it verbatim
|
|
target_name = f'{get_sanitized(Path(nested_epub).stem)}.epub'
|
|
target_path = os.path.join(os.path.dirname(file_input), target_name)
|
|
with open(target_path, 'wb') as out:
|
|
out.write(zf.read(nested_epub))
|
|
else:
|
|
# case 2/3 - epub contents are at root or under a single top dir
|
|
root_mimetype = 'mimetype' in names
|
|
prefix = ''
|
|
if not root_mimetype:
|
|
dirs = {n.split('/', 1)[0] for n in names if '/' in n}
|
|
cands = [d for d in dirs if f'{d}/mimetype' in names and f'{d}/META-INF/container.xml' in names]
|
|
if len(cands) != 1:
|
|
msg = f'Unsupported ZIP ebook wrapper: expected one EPUB root, found {len(cands)}'
|
|
print(msg)
|
|
return None
|
|
prefix = cands[0]
|
|
stem = Path(prefix).stem if prefix else Path(file_input).stem
|
|
target_name = f'{get_sanitized(stem)}.epub'
|
|
target_path = os.path.join(os.path.dirname(file_input), target_name)
|
|
strip = f'{prefix}/' if prefix else ''
|
|
members = [n for n in names if n.startswith(strip)]
|
|
mimetype_name = f'{strip}mimetype'
|
|
if mimetype_name not in members:
|
|
msg = 'Unsupported ZIP ebook wrapper: no mimetype entry'
|
|
print(msg)
|
|
return None
|
|
with zipfile.ZipFile(target_path, 'w') as out:
|
|
mt = zipfile.ZipInfo('mimetype')
|
|
mt.compress_type = zipfile.ZIP_STORED
|
|
out.writestr(mt, zf.read(mimetype_name))
|
|
for name in members:
|
|
if name == mimetype_name:
|
|
continue
|
|
arcname = name[len(strip):]
|
|
if not arcname:
|
|
continue
|
|
out.writestr(arcname, zf.read(name), zipfile.ZIP_DEFLATED)
|
|
session = context.get_session(session_id)
|
|
session['ebook'] = target_path
|
|
session['filename_noext'] = os.path.splitext(os.path.basename(target_path))[0]
|
|
session['epub_path'] = os.path.join(session['process_dir'], f"__{session['filename_noext']}.epub")
|
|
msg = f'Normalized EPUB package ZIP: {Path(file_input).name} -> {Path(target_path).name}'
|
|
print(msg)
|
|
return target_path
|
|
except zipfile.BadZipFile:
|
|
error = f'Unsupported ZIP ebook wrapper: bad ZIP file {file_input}'
|
|
print(error)
|
|
except Exception as e:
|
|
error = f'normalize_epub_zip(): {e}'
|
|
exception_alert(session_id, error)
|
|
return None
|
|
|
|
def convert2epub(session_id:str)->bool:
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
if session['cancellation_requested']:
|
|
return False
|
|
try:
|
|
title = False
|
|
author = False
|
|
ebook_convert = shutil.which('ebook-convert')
|
|
if not ebook_convert:
|
|
error = 'ebook-convert utility is not installed or not found.'
|
|
print(error)
|
|
return False
|
|
file_input = session['ebook']
|
|
if os.path.getsize(file_input) == 0:
|
|
error = f'Input file is empty: {file_input}'
|
|
print(error)
|
|
return False
|
|
file_ext = os.path.splitext(file_input)[1].lower()
|
|
if file_ext not in ebook_formats:
|
|
error = f'Unsupported file format: {file_ext}'
|
|
print(error)
|
|
return False
|
|
if file_ext == '.zip':
|
|
file_input = normalize_epub_zip(session_id, file_input)
|
|
if file_input is None:
|
|
return False
|
|
file_ext = '.epub'
|
|
if file_ext == '.txt':
|
|
with open(file_input, 'r', encoding='utf-8') as f:
|
|
text = f.read()
|
|
text = text.replace('\r\n', '\n')
|
|
text = re.sub(r'\n{2,}', f".{TTS_SML['pause']['static']}", text)
|
|
with open(file_input, 'w', encoding='utf-8') as f:
|
|
f.write(text)
|
|
elif file_ext == '.pdf':
|
|
msg = 'File input is a PDF. flatten it in XHTML…'
|
|
print(msg)
|
|
doc = fitz.open(file_input)
|
|
file_meta = doc.metadata
|
|
filename_noext = os.path.splitext(os.path.basename(session['ebook']))[0]
|
|
title = file_meta.get('title') or filename_noext
|
|
author = file_meta.get('author') or False
|
|
xhtml_pages = []
|
|
for i, page in enumerate(doc):
|
|
has_text = page.get_text('text').strip()
|
|
if has_text:
|
|
try:
|
|
xhtml_content = page.get_text('xhtml').strip()
|
|
except Exception as e:
|
|
print(f'Error extracting text from page {i+1}: {e}')
|
|
xhtml_content = ''
|
|
error = None
|
|
else:
|
|
xhtml_content = ''
|
|
error = None
|
|
if not xhtml_content:
|
|
msg = f'The page {i+1} seems to be image-based. Using OCR…'
|
|
show_alert(session_id, {"type": "warning", "msg": msg})
|
|
pix = page.get_pixmap(dpi=300)
|
|
img = Image.open(io.BytesIO(pix.tobytes('png')))
|
|
xhtml_content, error = ocr2xhtml(img, session['language'])
|
|
if xhtml_content:
|
|
xhtml_pages.append(xhtml_content)
|
|
else:
|
|
show_alert(session_id, {"type": "warning", "msg": error})
|
|
if xhtml_pages:
|
|
xhtml_body = '\n'.join(xhtml_pages)
|
|
xhtml_text = (
|
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
'<html xmlns="http://www.w3.org/1999/xhtml">\n'
|
|
'<head>\n'
|
|
f'<meta charset="utf-8"/>\n<title>{title}</title>\n'
|
|
'</head>\n'
|
|
'<body>\n'
|
|
f'{xhtml_body}\n'
|
|
'</body>\n'
|
|
'</html>\n'
|
|
)
|
|
file_input = os.path.join(session['process_dir'], f'{filename_noext}.xhtml')
|
|
with open(file_input, 'w', encoding='utf-8') as html_file:
|
|
html_file.write(xhtml_text)
|
|
else:
|
|
return False
|
|
elif file_ext == '.pptx':
|
|
from html import escape as html_escape
|
|
from pptx import Presentation as PptxPresentation
|
|
filename_noext = os.path.splitext(os.path.basename(session['ebook']))[0]
|
|
msg = f'File input is a presentation ({file_ext}). Extracting content…'
|
|
print(msg)
|
|
prs = PptxPresentation(file_input)
|
|
title = prs.core_properties.title or filename_noext
|
|
author = prs.core_properties.author or False
|
|
xhtml_pages = []
|
|
for i, slide in enumerate(prs.slides):
|
|
slide_texts = []
|
|
slide_images = []
|
|
for shape in slide.shapes:
|
|
if shape.has_text_frame:
|
|
for para in shape.text_frame.paragraphs:
|
|
text = para.text.strip()
|
|
if text:
|
|
slide_texts.append(text)
|
|
if shape.has_table:
|
|
for row in shape.table.rows:
|
|
row_texts = [c.text.strip() for c in row.cells if c.text.strip()]
|
|
if row_texts:
|
|
slide_texts.append(' | '.join(row_texts))
|
|
try:
|
|
slide_images.append(shape.image.blob)
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
if slide_texts:
|
|
xhtml_content = '\n'.join(f'<p>{html_escape(t)}</p>' for t in slide_texts)
|
|
elif slide_images:
|
|
msg = f'Slide {i+1} seems to be image-based. Using OCR…'
|
|
show_alert(session_id, {"type": "warning", "msg": msg})
|
|
xhtml_parts = []
|
|
for blob in slide_images:
|
|
img = Image.open(io.BytesIO(blob))
|
|
xhtml_content, error = ocr2xhtml(img, session['language'])
|
|
if xhtml_content:
|
|
xhtml_parts.append(xhtml_content)
|
|
else:
|
|
show_alert(session_id, {"type": "warning", "msg": error})
|
|
xhtml_content = '\n'.join(xhtml_parts) if xhtml_parts else ''
|
|
else:
|
|
xhtml_content = ''
|
|
if xhtml_content:
|
|
xhtml_pages.append(xhtml_content)
|
|
if xhtml_pages:
|
|
xhtml_body = '\n'.join(xhtml_pages)
|
|
xhtml_text = (
|
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
'<html xmlns="http://www.w3.org/1999/xhtml">\n'
|
|
'<head>\n'
|
|
f'<meta charset="utf-8"/>\n<title>{title}</title>\n'
|
|
'</head>\n'
|
|
'<body>\n'
|
|
f'{xhtml_body}\n'
|
|
'</body>\n'
|
|
'</html>\n'
|
|
)
|
|
file_input = os.path.join(session['process_dir'], f'{filename_noext}.xhtml')
|
|
with open(file_input, 'w', encoding='utf-8') as html_file:
|
|
html_file.write(xhtml_text)
|
|
else:
|
|
return False
|
|
elif file_ext == '.docx':
|
|
from docx import Document as DocxDocument
|
|
filename_noext = os.path.splitext(os.path.basename(session['ebook']))[0]
|
|
docx_doc = DocxDocument(file_input)
|
|
all_text = ''.join(p.text.strip() for p in docx_doc.paragraphs)
|
|
if not all_text:
|
|
for table in docx_doc.tables:
|
|
for row in table.rows:
|
|
for cell in row.cells:
|
|
all_text += cell.text.strip()
|
|
if all_text:
|
|
break
|
|
if all_text:
|
|
break
|
|
if all_text:
|
|
break
|
|
if not all_text:
|
|
msg = f'File input is a DOCX with no extractable text. Extracting images for OCR…'
|
|
print(msg)
|
|
title = docx_doc.core_properties.title or filename_noext
|
|
author = docx_doc.core_properties.author or False
|
|
xhtml_pages = []
|
|
for rel in docx_doc.part.rels.values():
|
|
if 'image' in rel.reltype:
|
|
try:
|
|
img = Image.open(io.BytesIO(rel.target_part.blob))
|
|
xhtml_content, error = ocr2xhtml(img, session['language'])
|
|
if xhtml_content:
|
|
xhtml_pages.append(xhtml_content)
|
|
else:
|
|
show_alert(session_id, {"type": "warning", "msg": error})
|
|
except Exception as e:
|
|
print(f'Error processing embedded image: {e}')
|
|
if xhtml_pages:
|
|
xhtml_body = '\n'.join(xhtml_pages)
|
|
xhtml_text = (
|
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
'<html xmlns="http://www.w3.org/1999/xhtml">\n'
|
|
'<head>\n'
|
|
f'<meta charset="utf-8"/>\n<title>{title}</title>\n'
|
|
'</head>\n'
|
|
'<body>\n'
|
|
f'{xhtml_body}\n'
|
|
'</body>\n'
|
|
'</html>\n'
|
|
)
|
|
file_input = os.path.join(session['process_dir'], f'{filename_noext}.xhtml')
|
|
with open(file_input, 'w', encoding='utf-8') as html_file:
|
|
html_file.write(xhtml_text)
|
|
else:
|
|
return False
|
|
elif file_ext in ['.png', '.jpg', '.jpeg', '.tif', '.tiff', '.bmp']:
|
|
filename_noext = os.path.splitext(os.path.basename(session['ebook']))[0]
|
|
msg = f'File input is an image ({file_ext}). Running OCR…'
|
|
print(msg)
|
|
img = Image.open(file_input)
|
|
xhtml_pages = []
|
|
page_count = 0
|
|
for i, frame in enumerate(ImageSequence.Iterator(img)):
|
|
page_count += 1
|
|
frame = frame.convert('RGB')
|
|
xhtml_content, error = ocr2xhtml(frame, session['language'])
|
|
if xhtml_content:
|
|
xhtml_pages.append(xhtml_content)
|
|
else:
|
|
show_alert(session_id, {"type": "warning", "msg": error})
|
|
if xhtml_pages:
|
|
xhtml_body = '\n'.join(xhtml_pages)
|
|
xhtml_text = (
|
|
'<?xml version="1.0" encoding="utf-8"?>\n'
|
|
'<html xmlns="http://www.w3.org/1999/xhtml">\n'
|
|
'<head>\n'
|
|
f'<meta charset="utf-8"/>\n<title>{filename_noext}</title>\n'
|
|
'</head>\n'
|
|
'<body>\n'
|
|
f'{xhtml_body}\n'
|
|
'</body>\n'
|
|
'</html>\n'
|
|
)
|
|
file_input = os.path.join(session['process_dir'], f'{filename_noext}.xhtml')
|
|
with open(file_input, 'w', encoding='utf-8') as html_file:
|
|
html_file.write(xhtml_text)
|
|
print(f'OCR completed for {page_count} image page(s).')
|
|
else:
|
|
return False
|
|
msg = f"Running command: {ebook_convert} {file_input} {session['epub_path']}"
|
|
print(msg)
|
|
cmd = [
|
|
ebook_convert, file_input, session['epub_path'],
|
|
'--input-encoding=utf-8',
|
|
'--output-profile=generic_eink',
|
|
'--flow-size=0',
|
|
'--chapter-mark=pagebreak',
|
|
'--page-breaks-before',
|
|
"//*[name()='h1' or name()='h2' or name()='h3' or name()='h4' or name()='h5']",
|
|
'--disable-font-rescaling',
|
|
'--pretty-print',
|
|
'--smarten-punctuation',
|
|
'--verbose'
|
|
]
|
|
if title:
|
|
cmd += ['--title', title]
|
|
if author:
|
|
cmd += ['--authors', author]
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding='utf-8'
|
|
)
|
|
print(result.stdout)
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
DependencyError(e)
|
|
error = f'convert2epub subprocess.CalledProcessError: {e.stderr}'
|
|
print(error)
|
|
return False
|
|
except FileNotFoundError as e:
|
|
DependencyError(e)
|
|
error = f'convert2epub FileNotFoundError: {e}'
|
|
print(error)
|
|
return False
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
error = f'convert2epub error: {e}'
|
|
print(error)
|
|
return False
|
|
|
|
def get_ebook_title(epubBook:EpubBook, all_docs:list[Any])->str|None:
|
|
# 1. Try metadata (official EPUB title)
|
|
meta_title = epubBook.get_metadata('DC','title')
|
|
if meta_title and meta_title[0][0].strip():
|
|
return meta_title[0][0].strip()
|
|
# 2. Try <title> in the head of the first XHTML document
|
|
if all_docs:
|
|
html = all_docs[0].get_content().decode('utf-8')
|
|
soup = BeautifulSoup(html,'html.parser')
|
|
title_tag = soup.select_one('head > title')
|
|
if title_tag and title_tag.text.strip():
|
|
return title_tag.text.strip()
|
|
# 3. Try <img alt = '…'> if no visible <title>
|
|
img = soup.find('img',alt = True)
|
|
if img:
|
|
alt = img['alt'].strip()
|
|
if alt and 'cover' not in alt.lower():
|
|
return alt
|
|
return None
|
|
|
|
def get_cover(epubBook:EpubBook, session_id:str)->bool|str:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
if session['cancellation_requested']:
|
|
return False
|
|
cover_image = None
|
|
cover_path = os.path.join(session['process_dir'], session['filename_noext'] + '.jpg')
|
|
for item in epubBook.get_items_of_type(ebooklib.ITEM_COVER):
|
|
cover_image = item.get_content()
|
|
break
|
|
if not cover_image:
|
|
for item in epubBook.get_items_of_type(ebooklib.ITEM_IMAGE):
|
|
if 'cover' in item.file_name.lower() or 'cover' in item.get_id().lower():
|
|
cover_image = item.get_content()
|
|
break
|
|
if cover_image:
|
|
# Open the image from bytes
|
|
image = Image.open(io.BytesIO(cover_image))
|
|
# Convert to RGB if needed (JPEG doesn't support alpha)
|
|
if image.mode in ('RGBA', 'P'):
|
|
image = image.convert('RGB')
|
|
image.save(cover_path, format = 'JPEG')
|
|
return cover_path
|
|
return True
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
return False
|
|
|
|
def get_blocks(session_id:str, epubBook:EpubBook)->list:
|
|
try:
|
|
msg = r'''
|
|
*******************************************************************************
|
|
NOTE:
|
|
The warning "Character xx not found in the vocabulary."
|
|
MEANS THE MODEL CANNOT INTERPRET THE CHARACTER AND WILL MAYBE GENERATE
|
|
(AS WELL AS WRONG PUNCTUATION POSITION) AN HALLUCINATION. THE BEST SOLUTION IS
|
|
TO MANUALLY REMOVE ALL UNRECOGNIZED CHARS AND WRONG PUNCTUATIONS FROM YOUR EBOOK
|
|
AND RESTART THE CONVERSION. TO IMPROVE THIS MODEL, IT NEEDS TO ADD THIS CHARACTER
|
|
INTO A NEW TRAINING MODEL. YOU CAN IMPROVE IT OR ASK TO A TRAINING MODEL EXPERT.
|
|
*******************************************************************************
|
|
'''
|
|
print(msg)
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
if session['cancellation_requested']:
|
|
return []
|
|
# Step 1: Extract TOC (Table of Contents)
|
|
try:
|
|
toc = epubBook.toc
|
|
toc_list = [
|
|
nt for item in toc if hasattr(item, 'title')
|
|
if (nt := normalize_text(str(item.title), session['language'], session['language_iso1'], session['tts_engine'])) is not None
|
|
]
|
|
except Exception as toc_error:
|
|
error = f'Error extracting Table of Content: {toc_error}'
|
|
show_alert(session_id, {"type": "warning", "msg": error})
|
|
# Get spine item IDs
|
|
spine_ids = [item[0] for item in epubBook.spine]
|
|
# Filter only spine documents (i.e., reading order)
|
|
all_docs = [
|
|
item for item in epubBook.get_items_of_type(ebooklib.ITEM_DOCUMENT)
|
|
if item.id in spine_ids
|
|
]
|
|
if not all_docs:
|
|
error = 'No document body found!'
|
|
print(error)
|
|
return []
|
|
title = get_ebook_title(epubBook, all_docs)
|
|
blocks = []
|
|
stanza_nlp = False
|
|
if session['language'] in year_to_decades_languages:
|
|
try:
|
|
stanza_model = f"stanza-{session['language_iso1']}"
|
|
stanza_nlp = loaded_tts.get(stanza_model, False)
|
|
if stanza_nlp:
|
|
msg = f"NLP model {stanza_model} loaded."
|
|
print(msg)
|
|
else:
|
|
use_gpu = True if (
|
|
(session['device'] == devices['CUDA']['proc'] and devices['CUDA']['found']) or
|
|
(session['device'] == devices['ROCM']['proc'] and devices['ROCM']['found']) or
|
|
(session['device'] == devices['XPU']['proc'] and devices['XPU']['found']) or
|
|
(session['device'] == devices['JETSON']['proc'] and devices['JETSON']['found'])
|
|
) else False
|
|
# only use mwt if the language supports it
|
|
stanza_lang = session['language_iso1']
|
|
stanza_has_mwt = False
|
|
try:
|
|
stanza_resources = stanza.resources.common.load_resources_json(os.getenv('STANZA_RESOURCES_DIR', stanza.resources.common.DEFAULT_MODEL_DIR))
|
|
stanza_has_mwt = 'mwt' in stanza_resources.get(stanza_lang, {})
|
|
except Exception:
|
|
pass
|
|
stanza_processors = 'tokenize,mwt,ner' if stanza_has_mwt else 'tokenize,ner'
|
|
stanza_nlp = stanza.Pipeline(stanza_lang, processors=stanza_processors, use_gpu=use_gpu, download_method=DownloadMethod.REUSE_RESOURCES, dir=os.getenv('STANZA_RESOURCES_DIR'))
|
|
if stanza_nlp:
|
|
session['stanza_cache'] = stanza_model
|
|
loaded_tts[stanza_model] = stanza_nlp
|
|
msg = f"NLP model {stanza_model} loaded!"
|
|
print(msg)
|
|
except (ConnectionError, TimeoutError) as e:
|
|
error = f'Stanza model download connection error: {e}. Retry later'
|
|
print(error)
|
|
return []
|
|
except Exception as e:
|
|
error = f'Stanza model initialization error: {e}'
|
|
print(error)
|
|
return []
|
|
is_num2words_compat = get_num2words_compat(session['language_iso1'])
|
|
non_text_filter = NonTextFilter(sml_pattern=SML_TAG_PATTERN, lang=session['language'])
|
|
try:
|
|
with zipfile.ZipFile(session['epub_path'], 'r') as zf:
|
|
zip_names = set(zf.namelist())
|
|
zip_basenames = {os.path.basename(n): n for n in zip_names}
|
|
for doc_idx, doc in enumerate(all_docs):
|
|
text = filter_blocks(session_id, doc_idx, doc, stanza_nlp, is_num2words_compat, non_text_filter, zf, zip_names, zip_basenames)
|
|
if text is None:
|
|
error = f'Error extracting content from document #{doc_idx + 1}; aborting conversion to avoid partial output.'
|
|
show_alert(session_id, {"type": "warning", "msg": error})
|
|
return []
|
|
blocks.append(text)
|
|
finally:
|
|
if stanza_nlp:
|
|
import gc, torch
|
|
try:
|
|
cache_key = session.get('stanza_cache')
|
|
if cache_key:
|
|
loaded_tts.pop(cache_key, None)
|
|
session['stanza_cache'] = None
|
|
except Exception:
|
|
pass
|
|
stanza_nlp = None
|
|
gc.collect()
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
torch.cuda.ipc_collect()
|
|
if len(blocks) == 0:
|
|
error = 'No blocks found! possible reason: file corrupted or need to convert images to text with OCR'
|
|
print(error)
|
|
return []
|
|
return blocks
|
|
return []
|
|
except Exception as e:
|
|
error = f'Error extracting main content pages: {e}'
|
|
DependencyError(error)
|
|
return []
|
|
|
|
def filter_blocks(session_id:str, idx:int, doc:EpubHtml, stanza_nlp:Pipeline, is_num2words_compat:bool, non_text_filter:NonTextFilter, zf:zipfile.ZipFile=None, zip_names:set=None, zip_basenames:dict=None)->str|None:
|
|
|
|
def _tuple_row(node:Any, last_text_char:str|None=None, in_heading:bool=False)->Generator[tuple[str, Any], None, None]|None:
|
|
try:
|
|
prev_child_had_data = False
|
|
for idx, child in enumerate(node.children):
|
|
current_child_had_data = False
|
|
if isinstance(child, NavigableString):
|
|
text = child.strip()
|
|
if text:
|
|
# Only add break if not inside a heading and previous had data
|
|
if prev_child_had_data and not in_heading:
|
|
yield ('break', sml_token("break"))
|
|
# Yield as heading if inside heading, else text
|
|
yield ('heading' if in_heading else 'text', text)
|
|
last_text_char = text[-1]
|
|
current_child_had_data = True
|
|
elif isinstance(child, Tag):
|
|
name = child.name.lower()
|
|
if name in heading_tags:
|
|
# Process heading tag and all its children as heading content
|
|
title = child.get_text(strip=True)
|
|
if title:
|
|
# Add break before heading if needed (only if not already in heading)
|
|
if prev_child_had_data and not in_heading:
|
|
yield ('break', sml_token("break"))
|
|
# Always process heading children recursively to handle nested elements
|
|
for inner in _tuple_row(child, last_text_char, in_heading=True):
|
|
yield inner
|
|
# After finishing the heading, yield a pause marker
|
|
yield ('pause', sml_token("pause"))
|
|
last_text_char = title[-1]
|
|
current_child_had_data = True
|
|
elif name == 'table':
|
|
if prev_child_had_data and not in_heading:
|
|
yield ('break', sml_token("break"))
|
|
yield ('table', child)
|
|
current_child_had_data = True
|
|
else:
|
|
return_data = False
|
|
if name in proc_tags:
|
|
is_header = False
|
|
# Add break before processing tag content if needed
|
|
if prev_child_had_data and name in break_tags and not in_heading:
|
|
yield ('break', sml_token("break"))
|
|
for inner in _tuple_row(child, last_text_char, in_heading=in_heading):
|
|
return_data = True
|
|
yield inner
|
|
if len(inner) > 1 and isinstance(inner[1], str) and inner[1]:
|
|
last_text_char = inner[1][-1]
|
|
current_child_had_data = True
|
|
if inner[0] in ('text', 'heading') and isinstance(inner[1], str) and inner[1]:
|
|
is_header = True
|
|
if return_data:
|
|
if name in break_tags and name != 'span' and not in_heading:
|
|
if is_header or (last_text_char and not last_text_char.isalnum() and not last_text_char.isspace()):
|
|
yield ('break', sml_token("break"))
|
|
elif name in pause_tags and not in_heading:
|
|
yield ('pause', sml_token("pause"))
|
|
else:
|
|
yield from _tuple_row(child, last_text_char, in_heading=in_heading)
|
|
current_child_had_data = True
|
|
if current_child_had_data:
|
|
prev_child_had_data = True
|
|
except Exception as e:
|
|
error = f'filter_blocks() _tuple_row() error: {e}'
|
|
DependencyError(error)
|
|
return None
|
|
|
|
def _num_repl(m):
|
|
s = m.group(0)
|
|
# leave years alone (already handled above)
|
|
if re.fullmatch(r"\d{4}", s):
|
|
return s
|
|
n = float(s) if '.' in s else int(s)
|
|
if is_num2words_compat:
|
|
return num2words(n, lang=(lang_iso1 or 'en'))
|
|
else:
|
|
return math2words(m, lang, lang_iso1, tts_engine, is_num2words_compat)
|
|
|
|
try:
|
|
msg = f'----------\nParsing doc {idx}'
|
|
print(msg)
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
lang, lang_iso1, tts_engine = session['language'], session['language_iso1'], session['tts_engine']
|
|
heading_tags = [f'h{i}' for i in range(1, 5)]
|
|
break_tags = ['br', 'p', 'span']
|
|
pause_tags = ['div']
|
|
proc_tags = heading_tags + break_tags + pause_tags
|
|
doc_body = doc.get_body_content()
|
|
raw_html = doc_body.decode('utf-8') if isinstance(doc_body, bytes) else doc_body
|
|
soup = BeautifulSoup(raw_html, 'html.parser')
|
|
body = soup.body
|
|
if not body:
|
|
msg = 'No body found. Skip to next doc…'
|
|
print(msg)
|
|
return ''
|
|
# Skip known non-chapter types
|
|
epub_type = body.get('epub:type', '').lower()
|
|
if not epub_type:
|
|
section_tag = soup.find('section')
|
|
if section_tag:
|
|
epub_type = section_tag.get('epub:type', '').lower()
|
|
excluded = {
|
|
'frontmatter', 'backmatter', 'toc', 'titlepage', 'colophon',
|
|
'acknowledgments', 'dedication', 'glossary', 'index',
|
|
'appendix', 'bibliography', 'copyright-page', 'landmark'
|
|
}
|
|
if any(part in epub_type for part in excluded):
|
|
msg = 'No body part. Skip to next doc…'
|
|
print(msg)
|
|
return ''
|
|
# remove scripts/styles
|
|
for tag in soup(['script', 'style']):
|
|
tag.decompose()
|
|
if not body.get_text(strip=True):
|
|
images = body.find_all('img') + body.find_all('image')
|
|
if images and zf:
|
|
msg = f'Doc {idx}: no text but {len(images)} image(s) detected. Running OCR…'
|
|
show_alert(session_id, {"type": "info", "msg": msg})
|
|
ocr_parts = []
|
|
doc_dir = os.path.dirname(doc.get_name())
|
|
for img_tag in images:
|
|
img_ref = (
|
|
img_tag.get('src')
|
|
or img_tag.get('href')
|
|
or img_tag.get('{http://www.w3.org/1999/xlink}href')
|
|
or img_tag.get('xlink:href')
|
|
)
|
|
if not img_ref:
|
|
continue
|
|
img_zip_path = os.path.normpath(os.path.join(doc_dir, img_ref)).replace('\\', '/')
|
|
if img_zip_path not in zip_names:
|
|
img_zip_path = zip_basenames.get(os.path.basename(img_ref))
|
|
if not img_zip_path:
|
|
print(f'Could not resolve image in EPUB: {img_ref}')
|
|
continue
|
|
try:
|
|
img_data = zf.read(img_zip_path)
|
|
img = Image.open(io.BytesIO(img_data))
|
|
img = img.convert('RGB')
|
|
xhtml_content, error = ocr2xhtml(img, lang)
|
|
if xhtml_content:
|
|
ocr_parts.append(xhtml_content)
|
|
else:
|
|
show_alert(session_id, {"type": "warning", "msg": error})
|
|
except Exception as ocr_err:
|
|
print(f'OCR error on {img_zip_path}: {ocr_err}')
|
|
tuples_list = list(_tuple_row(body))
|
|
if not tuples_list:
|
|
msg = 'No body text and no images found. Skip to next doc…'
|
|
print(msg)
|
|
return ''
|
|
msg = f'Parsing xhtml markers…'
|
|
print(msg)
|
|
text_list = []
|
|
handled_tables = set()
|
|
prev_typ = None
|
|
for typ, payload in tuples_list:
|
|
if typ == 'heading':
|
|
text_list.append(payload.strip())
|
|
elif typ in ('break', 'pause'):
|
|
# Original logic: add token if different from previous type
|
|
if prev_typ != typ:
|
|
token = sml_token(typ)
|
|
if text_list and text_list[-1] not in {v['static'] for v in TTS_SML.values() if 'static' in v}:
|
|
sep = '; ' if prev_typ == 'heading' else ''
|
|
text_list[-1] = text_list[-1] + sep + token
|
|
else:
|
|
text_list.append(token)
|
|
elif typ == 'table':
|
|
table = payload
|
|
if table in handled_tables:
|
|
prev_typ = typ
|
|
continue
|
|
handled_tables.add(table)
|
|
rows = table.find_all('tr')
|
|
if not rows:
|
|
prev_typ = typ
|
|
continue
|
|
headers = [c.get_text(strip=True) for c in rows[0].find_all(['td', 'th'])]
|
|
for row in rows[1:]:
|
|
cells = [c.get_text(strip=True).replace('\xa0', ' ') for c in row.find_all('td')]
|
|
if not cells:
|
|
continue
|
|
if len(cells) == len(headers) and headers:
|
|
line = ' — '.join(f'{h}: {c}' for h, c in zip(headers, cells))
|
|
else:
|
|
line = ' — '.join(cells)
|
|
if line:
|
|
text_list.append(line.strip())
|
|
else:
|
|
text = payload.strip()
|
|
if text:
|
|
text_list.append(text)
|
|
prev_typ = typ
|
|
msg = f'Flattening as raw text…'
|
|
print(msg)
|
|
max_chars = int(language_mapping[lang]['max_chars'] / 1.5)
|
|
clean_list = []
|
|
i = 0
|
|
while i < len(text_list):
|
|
current = text_list[i]
|
|
if current in {v['static'] for v in TTS_SML.values() if "static" in v}:
|
|
if clean_list:
|
|
prev = clean_list[-1]
|
|
if prev in {v['static'] for v in TTS_SML.values() if "static" in v}:
|
|
i += 1
|
|
continue
|
|
clean_list.append(current)
|
|
i += 1
|
|
continue
|
|
clean_list.append(current)
|
|
i += 1
|
|
text = ' '.join(clean_list)
|
|
if not re.search(r"[^\W_]", text):
|
|
error = 'No valid text found!'
|
|
print(error)
|
|
return None
|
|
# clean SML tags badly coded
|
|
res, text = normalize_sml_tags(text)
|
|
if res is False:
|
|
show_alert(session_id, {"type": "warning", "msg": text})
|
|
return None
|
|
# remove any [break] between words or cutting words
|
|
break_token = re.escape(sml_token('break'))
|
|
strip_break_spaces_re = re.compile(rf'\s*{break_token}\s*')
|
|
break_between_alnum_re = re.compile(rf'(?<=[\w]){break_token}(?=[\w])', flags=re.UNICODE)
|
|
text = strip_break_spaces_re.sub(sml_token('break'), text)
|
|
text = break_between_alnum_re.sub(' ', text)
|
|
# strip non-prose content; preserves math signs for math2words
|
|
text = non_text_filter(text)
|
|
# escape all SML tags to not be touched by any text treatment
|
|
text, sml_blocks = escape_sml(text)
|
|
if stanza_nlp:
|
|
msg = 'Converting dates and years to words…'
|
|
print(msg)
|
|
re_ordinal = re.compile(
|
|
r'(?<!\w)(0?[1-9]|[12][0-9]|3[01])(?:\s|\u00A0)*(?:st|nd|rd|th)(?!\w)',
|
|
re.IGNORECASE
|
|
)
|
|
re_num = re.compile(r'(?<!\w)[-+]?\d+(?:\.\d+)?(?!\w)')
|
|
text = unicodedata.normalize('NFKC', text).replace('\u00A0', ' ')
|
|
re_year = re.compile(r'\b(?:1[0-9]|20)\d{2}\b')
|
|
if re_num.search(text) and (re_ordinal.search(text) or re_year.search(text)):
|
|
date_spans = get_date_entities(text, stanza_nlp)
|
|
if date_spans:
|
|
result = []
|
|
last_pos = 0
|
|
for start, end, date_text in date_spans:
|
|
result.append(text[last_pos:start])
|
|
# 1) convert 4-digit years (your original behavior)
|
|
processed = re.sub(
|
|
r"\b\d{4}\b",
|
|
lambda m: year2words(m.group(), lang, lang_iso1, is_num2words_compat),
|
|
date_text
|
|
)
|
|
# 2) convert ordinal days like "16th"/"16 th"->"sixteenth"
|
|
if is_num2words_compat:
|
|
processed = re_ordinal.sub(
|
|
lambda m: num2words(int(m.group(1)), to='ordinal', lang=(lang_iso1 or 'en')),
|
|
processed
|
|
)
|
|
else:
|
|
processed = re_ordinal.sub(
|
|
lambda m: math2words(m.group(), lang, lang_iso1, tts_engine, is_num2words_compat),
|
|
processed
|
|
)
|
|
# 3) convert other numbers (skip 4-digit years)
|
|
processed = re_num.sub(_num_repl, processed)
|
|
result.append(processed)
|
|
last_pos = end
|
|
result.append(text[last_pos:])
|
|
text = ' '.join(result)
|
|
else:
|
|
if is_num2words_compat:
|
|
text = re_ordinal.sub(
|
|
lambda m: num2words(int(m.group(1)), to='ordinal', lang=(lang_iso1 or 'en')),
|
|
text
|
|
)
|
|
else:
|
|
text = re_ordinal.sub(
|
|
lambda m: math2words(int(m.group(1)), lang, lang_iso1, tts_engine, is_num2words_compat),
|
|
text
|
|
)
|
|
text = re.sub(
|
|
r"\b\d{4}\b",
|
|
lambda m: year2words(m.group(), lang, lang_iso1, is_num2words_compat),
|
|
text
|
|
)
|
|
msg = 'Convert romans to numbers…'
|
|
print(msg)
|
|
text = roman2number(text)
|
|
msg = 'Convert time to words…'
|
|
print(msg)
|
|
text = clock2words(text, lang, lang_iso1, tts_engine, is_num2words_compat)
|
|
msg = 'Convert numbers, maths signs to words…'
|
|
print(msg)
|
|
text = math2words(text, lang, lang_iso1, tts_engine, is_num2words_compat)
|
|
msg = 'Normalize text…'
|
|
print(msg)
|
|
text = normalize_text(text, lang, lang_iso1, tts_engine)
|
|
text = restore_sml(text, sml_blocks)
|
|
return text
|
|
return None
|
|
except Exception as e:
|
|
error = f'filter_blocks() error: {e}'
|
|
DependencyError(error)
|
|
return None
|
|
|
|
def get_sentences(session_id:str, text:str)->list|None:
|
|
|
|
def _split_inclusive(text:str, pattern:re.Pattern[str])->list[str]:
|
|
result = []
|
|
last_end = 0
|
|
for match in pattern.finditer(text):
|
|
result.append(text[last_end:match.end()].strip())
|
|
last_end = match.end()
|
|
if last_end < len(text):
|
|
tail = text[last_end:].strip()
|
|
if tail:
|
|
result.append(tail)
|
|
return result
|
|
|
|
def _split_sentence_on_sml(sentence:str)->list[str]:
|
|
parts:list[str] = []
|
|
last = 0
|
|
i = 0
|
|
n = len(sentence)
|
|
while i < n:
|
|
m = SML_TAG_PATTERN.match(sentence, i)
|
|
if m:
|
|
if m.start() > last:
|
|
parts.append(sentence[last:m.start()])
|
|
parts.append(m.group(0))
|
|
i = m.end()
|
|
last = i
|
|
continue
|
|
if ord(sentence[i]) >= sml_escape_tag:
|
|
if i > last:
|
|
parts.append(sentence[last:i])
|
|
j = i
|
|
while j < n and ord(sentence[j]) >= sml_escape_tag:
|
|
j += 1
|
|
parts.append(sentence[i:j])
|
|
i = j
|
|
last = i
|
|
continue
|
|
i += 1
|
|
if last < n:
|
|
parts.append(sentence[last:])
|
|
return parts
|
|
|
|
def _strip_escaped_sml(s:str)->str:
|
|
return ''.join(c for c in s if ord(c) < sml_escape_tag)
|
|
|
|
def _clean_len(s:str)->int:
|
|
return len(_strip_escaped_sml(s))
|
|
|
|
def _is_latin_only(s:str)->bool:
|
|
s = _strip_escaped_sml(s)
|
|
s = re.sub(r'[^\w\s]', '', s, flags=re.UNICODE)
|
|
has_latin = bool(re.search(r'[A-Za-z]', s))
|
|
has_nonlatin = bool(re.search(r'[^\x00-\x7F]', s))
|
|
return has_latin and not has_nonlatin
|
|
|
|
def _segment_ideogramms(text:str)->list[str]:
|
|
result = []
|
|
try:
|
|
if lang in ['yue','yue-Hant','yue-Hans','zh-yue','cantonese']:
|
|
import pycantonese as pc
|
|
result.extend([t for t in pc.segment(text) if t.strip()])
|
|
elif lang == 'zho':
|
|
import jieba
|
|
jieba.dt.cache_file = os.path.join(models_dir, 'jieba.cache')
|
|
result.extend([t for t in jieba.cut(text) if t.strip()])
|
|
elif lang == 'jpn':
|
|
import nagisa
|
|
result.extend(nagisa.tagging(text).words)
|
|
elif lang == 'kor':
|
|
from soynlp.tokenizer import LTokenizer
|
|
ltokenizer = LTokenizer()
|
|
result.extend([t for t in ltokenizer.tokenize(text) if t.strip()])
|
|
elif lang in ['tha','lao','mya','khm']:
|
|
from pythainlp.tokenize import word_tokenize
|
|
result.extend([t for t in word_tokenize(text, engine='newmm') if t.strip()])
|
|
else:
|
|
result.append(text.strip())
|
|
return result
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
return [text]
|
|
|
|
def _join_ideogramms(idg_list:list[str])->str:
|
|
try:
|
|
buffer = ''
|
|
prev_latin = False
|
|
prev_nonlatin = False
|
|
for token in idg_list:
|
|
cur_starts_latin = bool(re.match(r'[A-Za-z0-9]', token))
|
|
cur_starts_nonlatin = bool(re.match(r'[^\x00-\x7F]', token))
|
|
if buffer:
|
|
if (prev_latin and (cur_starts_latin or cur_starts_nonlatin)) or (prev_nonlatin and cur_starts_latin):
|
|
buffer += ' '
|
|
elif len(buffer) + len(token) > max_chars:
|
|
yield buffer
|
|
buffer = ''
|
|
buffer += token
|
|
prev_latin = bool(re.search(r'[A-Za-z0-9]$', token))
|
|
prev_nonlatin = bool(re.search(r'[^\x00-\x7F]$', token))
|
|
if buffer:
|
|
yield buffer
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
if buffer:
|
|
yield buffer
|
|
|
|
def _is_pure_escaped_sml(s:str)->bool:
|
|
return bool(s) and all(ord(c) >= sml_escape_tag for c in s)
|
|
|
|
def _strip_leading_noise(s:str)->str:
|
|
i = 0
|
|
n = len(s)
|
|
while i < n:
|
|
c = s[i]
|
|
if c.isalnum() or c == '_' or c.isspace() or ord(c) >= sml_escape_tag:
|
|
break
|
|
i += 1
|
|
return s[i:].lstrip()
|
|
|
|
def _force_split_segment(segment:str)->list[str]:
|
|
results = []
|
|
rest = segment
|
|
hard_set = tuple(punctuation_split_hard_set)
|
|
soft_set = tuple(punctuation_split_soft_set)
|
|
while rest:
|
|
if _clean_len(rest) <= max_chars:
|
|
results.append(rest.strip())
|
|
break
|
|
cut = rest[:max_chars + 1]
|
|
best_idx = -1
|
|
# 1) regress to the last hard punctuation in the window
|
|
for i in range(len(cut) - 1, -1, -1):
|
|
if cut[i] in hard_set:
|
|
best_idx = i + 1
|
|
break
|
|
# 2) no hard punct -> regress to the last soft punctuation
|
|
if best_idx == -1:
|
|
for i in range(len(cut) - 1, -1, -1):
|
|
if cut[i] in soft_set:
|
|
best_idx = i + 1
|
|
break
|
|
# 3) no punctuation at all -> fall back to last space
|
|
if best_idx == -1:
|
|
idx = cut.rfind(' ')
|
|
if idx > 0:
|
|
best_idx = idx
|
|
# 4) last resort -> hard cut at max_chars
|
|
if best_idx <= 0:
|
|
best_idx = max_chars
|
|
# Safety: never cut inside an SML group
|
|
while best_idx < len(rest) and ord(rest[best_idx]) >= sml_escape_tag:
|
|
best_idx += 1
|
|
left = rest[:best_idx].strip()
|
|
right = rest[best_idx:].strip()
|
|
if not left or right == rest:
|
|
results.append(rest.strip())
|
|
break
|
|
results.append(left)
|
|
rest = right
|
|
return results
|
|
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not session:
|
|
return None
|
|
lang = session['language']
|
|
if session.get('translate_enabled') and session.get('translate'):
|
|
lang = session['translate']
|
|
tts_engine = session['tts_engine']
|
|
max_chars = int(language_mapping[lang]['max_chars'] / 2)
|
|
|
|
text, sml_blocks = escape_sml(text)
|
|
assert not SML_TAG_PATTERN.search(text)
|
|
|
|
# Tokenize into content and SML runs
|
|
segments = []
|
|
idx = 0
|
|
tlen = len(text)
|
|
current_text = []
|
|
while idx < tlen:
|
|
c = text[idx]
|
|
if ord(c) >= sml_escape_tag:
|
|
if current_text:
|
|
segments.append(('text', ''.join(current_text)))
|
|
current_text = []
|
|
start = idx
|
|
while idx < tlen and ord(text[idx]) >= sml_escape_tag:
|
|
idx += 1
|
|
segments.append(('sml', text[start:idx]))
|
|
else:
|
|
current_text.append(c)
|
|
idx += 1
|
|
if current_text:
|
|
segments.append(('text', ''.join(current_text)))
|
|
|
|
# SINGLE inline buffer — SML stays in position next to its surrounding text.
|
|
# On overflow, cut at the LAST SML position in the buffer (a natural pause point).
|
|
final_list = []
|
|
buffer = []
|
|
current_len = 0
|
|
|
|
for seg_type, seg_content in segments:
|
|
if seg_type == 'sml':
|
|
buffer.append(seg_content)
|
|
continue
|
|
|
|
seg_clean_len = _clean_len(seg_content)
|
|
potential_len = current_len + seg_clean_len
|
|
|
|
if potential_len <= max_chars:
|
|
buffer.append(seg_content)
|
|
current_len = potential_len
|
|
continue
|
|
|
|
# Doesn't fit. Try to cut at the rightmost SML run in the buffer.
|
|
combined = ''.join(buffer)
|
|
cut_idx = -1
|
|
j = len(combined) - 1
|
|
while j >= 0:
|
|
if ord(combined[j]) >= sml_escape_tag:
|
|
cut_idx = j + 1
|
|
break
|
|
j -= 1
|
|
|
|
cut_done = False
|
|
if 0 < cut_idx <= len(combined):
|
|
part1 = combined[:cut_idx]
|
|
part2 = combined[cut_idx:]
|
|
if seg_clean_len <= max_chars and _clean_len(part2) + seg_clean_len <= max_chars:
|
|
if _strip_escaped_sml(part1).strip():
|
|
final_list.append(part1.strip())
|
|
elif part1.strip() and final_list:
|
|
# part1 is pure SML — attach to previous sentence, no standalone tag
|
|
final_list[-1] = final_list[-1].rstrip() + ' ' + part1.strip()
|
|
buffer = [part2, seg_content] if part2 else [seg_content]
|
|
current_len = _clean_len(part2) + seg_clean_len
|
|
cut_done = True
|
|
|
|
if not cut_done:
|
|
# No usable SML cut. Flush buffer wholesale (force-split if too long).
|
|
if _strip_escaped_sml(combined).strip():
|
|
if current_len <= max_chars:
|
|
final_list.append(combined.strip())
|
|
else:
|
|
final_list.extend(_force_split_segment(combined.strip()))
|
|
if seg_clean_len > max_chars:
|
|
parts = _force_split_segment(seg_content)
|
|
for p in parts[:-1]:
|
|
if p:
|
|
final_list.append(p)
|
|
buffer = [parts[-1]]
|
|
current_len = _clean_len(parts[-1])
|
|
else:
|
|
buffer = [seg_content]
|
|
current_len = seg_clean_len
|
|
else:
|
|
# Buffer is pure SML — carry forward, never emit a standalone [break]
|
|
pending = combined
|
|
if seg_clean_len > max_chars:
|
|
parts = _force_split_segment(seg_content)
|
|
if parts and pending.strip():
|
|
parts[0] = pending + parts[0]
|
|
for p in parts[:-1]:
|
|
if p:
|
|
final_list.append(p)
|
|
buffer = [parts[-1]]
|
|
current_len = _clean_len(parts[-1])
|
|
else:
|
|
buffer = [pending, seg_content] if pending.strip() else [seg_content]
|
|
current_len = seg_clean_len
|
|
|
|
# Final flush
|
|
if buffer:
|
|
combined = ''.join(buffer).strip()
|
|
if combined:
|
|
if _strip_escaped_sml(combined).strip():
|
|
if _clean_len(combined) <= max_chars:
|
|
final_list.append(combined)
|
|
else:
|
|
final_list.extend(_force_split_segment(combined))
|
|
elif final_list:
|
|
# Trailing pure-SML — attach to last sentence, no standalone
|
|
final_list[-1] = final_list[-1].rstrip() + ' ' + combined
|
|
|
|
final_list = [_strip_leading_noise(s) for s in final_list if s.strip()]
|
|
final_list = [s for s in final_list if s]
|
|
|
|
# Merge orphan-short sentences. A sentence below max_chars/2 is "too short";
|
|
# absorb it into the previous (preferred) or next sentence when that fits.
|
|
merge_threshold = max_chars // 2
|
|
merge_ceiling = max_chars + max_chars // 2 # max_chars + overhead of max_chars/2
|
|
|
|
merged_list = []
|
|
i = 0
|
|
n = len(final_list)
|
|
while i < n:
|
|
cur = final_list[i].strip()
|
|
if not cur:
|
|
i += 1
|
|
continue
|
|
cur_len = _clean_len(cur)
|
|
if cur_len <= merge_threshold:
|
|
# 1) try to attach to the previous sentence
|
|
if merged_list:
|
|
prev = merged_list[-1]
|
|
if _clean_len(prev) + 1 + cur_len <= merge_ceiling:
|
|
merged_list[-1] = prev.rstrip() + ' ' + cur.lstrip()
|
|
i += 1
|
|
continue
|
|
# 2) otherwise try to glue it onto the next sentence
|
|
if i + 1 < n:
|
|
nxt = final_list[i + 1].strip()
|
|
if cur_len + 1 + _clean_len(nxt) <= merge_ceiling:
|
|
merged_list.append(cur.rstrip() + ' ' + nxt.lstrip())
|
|
i += 2
|
|
continue
|
|
merged_list.append(cur)
|
|
i += 1
|
|
final_list = merged_list
|
|
|
|
if lang in ['zho', 'jpn', 'kor', 'tha', 'lao', 'mya', 'khm']:
|
|
result = []
|
|
for s in final_list:
|
|
parts = _split_sentence_on_sml(s)
|
|
for part in parts:
|
|
part = part.strip()
|
|
if not part:
|
|
continue
|
|
if _is_pure_escaped_sml(part) or SML_TAG_PATTERN.fullmatch(part):
|
|
result.append(part)
|
|
continue
|
|
tokens = _segment_ideogramms(part)
|
|
if isinstance(tokens, list):
|
|
result.extend([t for t in tokens if t.strip()])
|
|
else:
|
|
tokens = tokens.strip()
|
|
if tokens:
|
|
result.append(tokens)
|
|
ideogram_list = []
|
|
for s in _join_ideogramms(result):
|
|
if not _is_latin_only(s):
|
|
ideogram_list.append(s)
|
|
if ideogram_list:
|
|
ideogram_list = [restore_sml(s, sml_blocks) for s in ideogram_list]
|
|
return ideogram_list
|
|
|
|
if final_list:
|
|
final_list = [restore_sml(s, sml_blocks) for s in final_list]
|
|
return final_list
|
|
except Exception as e:
|
|
print(f'get_sentences() error: {e}')
|
|
return None
|
|
|
|
def get_sanitized(str:str, replacement:str='_')->str:
|
|
str = str.replace('&', 'And')
|
|
forbidden_chars = r'[<>:"/\\|?*\x00-\x1F ()]'
|
|
sanitized = re.sub(r'\s+', replacement, str)
|
|
sanitized = re.sub(forbidden_chars, replacement, sanitized)
|
|
sanitized = sanitized.strip('_')
|
|
return sanitized
|
|
|
|
def get_date_entities(text:str, stanza_nlp:Pipeline)->list[tuple[int,int,str]]|bool:
|
|
try:
|
|
doc = stanza_nlp(text)
|
|
date_spans = []
|
|
for ent in doc.ents:
|
|
if ent.type == 'DATE':
|
|
date_spans.append((ent.start_char, ent.end_char, ent.text))
|
|
return date_spans
|
|
except Exception as e:
|
|
error = f'get_date_entities() error: {e}'
|
|
print(error)
|
|
return False
|
|
|
|
def get_num2words_compat(lang_iso1:str)->bool:
|
|
try:
|
|
test = num2words(1, lang=lang_iso1.replace('zh', 'zh_CN'))
|
|
return True
|
|
except NotImplementedError:
|
|
return False
|
|
except Exception as e:
|
|
return False
|
|
|
|
def set_formatted_number(text:str, lang:str, lang_iso1:str, is_num2words_compat:bool, max_single_value:int=999_999_999_999_999_999)->str:
|
|
# match up to 18 digits, optional “,…” groups (allowing spaces or NBSP after comma), optional decimal of up to 12 digits
|
|
# handle optional range with dash/en dash/em dash between numbers, and allow trailing punctuation
|
|
number_re = re.compile(
|
|
r'(?<!\w)'
|
|
r'(\d{1,18}(?:,\s*\d{1,18})*(?:\.\d{1,12})?)' # first number
|
|
r'(?:\s*([-–—])\s*' # dash type
|
|
r'(\d{1,18}(?:,\s*\d{1,18})*(?:\.\d{1,12})?))?' # optional second number
|
|
r'([^\w\s]*)', # optional trailing punctuation
|
|
re.UNICODE
|
|
)
|
|
|
|
def _normalize_commas(num_str:str)->str:
|
|
# ormalize number string to standard comma format: 1,234,567
|
|
tok = num_str.replace('\u00A0', '').replace(' ', '')
|
|
if '.' in tok:
|
|
integer_part, decimal_part = tok.split('.', 1)
|
|
integer_part = integer_part.replace(',', '')
|
|
integer_part = "{:,}".format(int(integer_part))
|
|
return f'{integer_part}.{decimal_part}'
|
|
else:
|
|
integer_part = tok.replace(',', '')
|
|
return "{:,}".format(int(integer_part))
|
|
|
|
def _clean_single_num(num_str:str)->str:
|
|
tok = unicodedata.normalize('NFKC', num_str)
|
|
if tok.lower() in ('inf', 'infinity', 'nan'):
|
|
return tok
|
|
clean = tok.replace(',', '').replace('\u00A0', '').replace(' ', '')
|
|
try:
|
|
num = float(clean) if '.' in clean else int(clean)
|
|
except (ValueError, OverflowError):
|
|
return tok
|
|
if not math.isfinite(num) or abs(num) > max_single_value:
|
|
return tok
|
|
|
|
# Normalize commas before final output
|
|
tok = _normalize_commas(tok)
|
|
|
|
if is_num2words_compat:
|
|
new_lang_iso1 = lang_iso1.replace('zh', 'zh_CN')
|
|
return num2words(num, lang=new_lang_iso1)
|
|
else:
|
|
phoneme_map = language_math_phonemes.get(
|
|
lang,
|
|
language_math_phonemes.get(default_language_code, language_math_phonemes['eng'])
|
|
)
|
|
return ' '.join(phoneme_map.get(ch, ch) for ch in str(num))
|
|
|
|
def clean_match(match:re.Match)->str:
|
|
first_num = _clean_single_num(match.group(1))
|
|
dash_char = match.group(2) or ''
|
|
second_num = _clean_single_num(match.group(3)) if match.group(3) else ''
|
|
trailing = match.group(4) or ''
|
|
if second_num:
|
|
return f'{first_num}{dash_char}{second_num}{trailing}'
|
|
else:
|
|
return f'{first_num}{trailing}'
|
|
|
|
return number_re.sub(clean_match, text)
|
|
|
|
def year2words(year_str:str, lang:str, lang_iso1:str, is_num2words_compat:bool)->str|bool:
|
|
try:
|
|
year = int(year_str)
|
|
first_two = int(year_str[:2])
|
|
last_two = int(year_str[2:])
|
|
lang_iso1 = lang_iso1 if lang in language_math_phonemes.keys() else default_language_code
|
|
lang_iso1 = lang_iso1.replace('zh', 'zh_CN')
|
|
if not year_str.isdigit() or len(year_str) != 4 or last_two < 10:
|
|
if is_num2words_compat:
|
|
return num2words(year, lang=lang_iso1)
|
|
else:
|
|
return ' '.join(language_math_phonemes[lang].get(ch, ch) for ch in year_str)
|
|
if is_num2words_compat:
|
|
return f'{num2words(first_two, lang=lang_iso1)} {num2words(last_two, lang=lang_iso1)}'
|
|
else:
|
|
return ' '.join(language_math_phonemes[lang].get(ch, ch) for ch in first_two) + ' ' + ' '.join(language_math_phonemes[lang].get(ch, ch) for ch in last_two)
|
|
except Exception as e:
|
|
error = f'year2words() error: {e}'
|
|
print(error)
|
|
return False
|
|
|
|
def clock2words(text:str, lang:str, lang_iso1:str, tts_engine:str, is_num2words_compat:bool)->str:
|
|
|
|
def _n2w(n:int)->str:
|
|
key = (n, lang, is_num2words_compat)
|
|
if key in _n2w_cache:
|
|
return _n2w_cache[key]
|
|
if is_num2words_compat:
|
|
word = num2words(n, lang=lang_iso1)
|
|
else:
|
|
word = math2words(n, lang, lang_iso1, tts_engine, is_num2words_compat)
|
|
if not isinstance(word, str):
|
|
word = str(word)
|
|
_n2w_cache[key] = word
|
|
return word
|
|
|
|
def _repl_num(m:re.Match)->str:
|
|
# Reject enumeration patterns like "(1.2)"
|
|
start, end = m.start(), m.end()
|
|
if (
|
|
start > 0 and end < len(text)
|
|
and text[start - 1] == '('
|
|
and text[end] == ')'
|
|
):
|
|
return m.group(0)
|
|
# Parse hh[:mm[:ss]]
|
|
try:
|
|
h = int(m.group(1))
|
|
mnt = int(m.group(2))
|
|
sec = m.group(3)
|
|
sec = int(sec) if sec is not None else None
|
|
except Exception:
|
|
return m.group(0)
|
|
# basic validation; if out of range, keep original
|
|
if not (0 <= h <= 23 and 0 <= mnt <= 59 and (sec is None or 0 <= sec <= 59)):
|
|
return m.group(0)
|
|
# If no language clock rules, just say numbers plainly
|
|
if not lc:
|
|
parts = [_n2w(h)]
|
|
if mnt != 0:
|
|
parts.append(_n2w(mnt))
|
|
if sec is not None and sec > 0:
|
|
parts.append(_n2w(sec))
|
|
return ' '.join(parts)
|
|
next_hour = (h + 1) % 24
|
|
special_hours = lc.get('special_hours', {})
|
|
if mnt == 0 and (sec is None or sec == 0):
|
|
if h in special_hours:
|
|
phrase = special_hours[h]
|
|
else:
|
|
phrase = lc['oclock'].format(hour=_n2w(h))
|
|
elif mnt == 15:
|
|
phrase = lc['quarter_past'].format(hour=_n2w(h))
|
|
elif mnt == 30:
|
|
phrase = lc['half_past'].format(hour=_n2w(h), next_hour=_n2w(next_hour))
|
|
elif mnt == 45:
|
|
phrase = lc['quarter_to'].format(next_hour=_n2w(next_hour))
|
|
elif mnt < 30:
|
|
phrase = lc['past'].format(hour=_n2w(h), minute=_n2w(mnt)) if mnt != 0 else lc['oclock'].format(hour=_n2w(h))
|
|
else:
|
|
minute_to_hour = 60 - mnt
|
|
phrase = lc['to'].format(
|
|
next_hour=_n2w(next_hour),
|
|
minute=_n2w(minute_to_hour),
|
|
minute_to_hour=_n2w(minute_to_hour)
|
|
)
|
|
if sec is not None and sec > 0:
|
|
second_phrase = lc['second'].format(second=_n2w(sec))
|
|
phrase = lc['full'].format(phrase=phrase, second_phrase=second_phrase)
|
|
return phrase
|
|
|
|
time_rx = re.compile(
|
|
r'\b([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?\b'
|
|
)
|
|
lc = language_clock.get(lang) if 'language_clock' in globals() else None
|
|
_n2w_cache = {}
|
|
return time_rx.sub(_repl_num, text)
|
|
|
|
def math2words(text:str, lang:str, lang_iso1:str, tts_engine:str, is_num2words_compat:bool)->str:
|
|
|
|
def _repl_ambiguous(match:re.Match)->str:
|
|
# handles "num SYMBOL num" and "SYMBOL num"
|
|
if match.group(2) and match.group(2) in ambiguous_replacements:
|
|
return f'{match.group(1)} {ambiguous_replacements[match.group(2)]} {match.group(3)}'
|
|
if match.group(3) and match.group(3) in ambiguous_replacements:
|
|
return f'{ambiguous_replacements[match.group(3)]} {match.group(4)}'
|
|
return match.group(0)
|
|
|
|
def _ordinal_to_words(m:re.Match)->str:
|
|
n = int(m.group(1))
|
|
if is_num2words_compat:
|
|
try:
|
|
return num2words(n, to='ordinal', lang=(lang_iso1 or 'en'))
|
|
except Exception:
|
|
pass
|
|
# If num2words isn't available/compatible, keep original token as-is.
|
|
return m.group(0)
|
|
|
|
# Matches any digits + optional space/NBSP + st/nd/rd/th, not glued into words.
|
|
re_ordinal = re.compile(r'(?<!\w)(\d+)(?:\s|\u00A0)*(?:st|nd|rd|th)(?!\w)')
|
|
text = re.sub(r'(\d)\)', r'\1 : ', text)
|
|
text = re_ordinal.sub(_ordinal_to_words, text)
|
|
# Symbol phonemes
|
|
ambiguous_symbols = {"-", "/", "*", "x"}
|
|
phonemes_list = language_math_phonemes.get(lang, language_math_phonemes[default_language_code])
|
|
replacements = {k: v for k, v in phonemes_list.items() if not k.isdigit() and k not in [',', '.']}
|
|
normal_replacements = {k: v for k, v in replacements.items() if k not in ambiguous_symbols}
|
|
ambiguous_replacements = {k: v for k, v in replacements.items() if k in ambiguous_symbols}
|
|
# Replace unambiguous symbols everywhere
|
|
if normal_replacements:
|
|
sym_pat = r'(' + '|'.join(re.escape(k) for k in sorted(normal_replacements, key=len, reverse=True)) + r')'
|
|
text = re.sub(sym_pat, lambda m: f' {normal_replacements[m.group(1)]} ', text)
|
|
# Replace ambiguous symbols only in valid equation contexts
|
|
if ambiguous_replacements:
|
|
ambiguous_pattern = (
|
|
r'(?<!\S)' # no non-space before
|
|
r'(\d+)\s*([-/*x])\s*(\d+)' # num SYMBOL num
|
|
r'(?!\S)' # no non-space after
|
|
r'|' # or
|
|
r'(?<!\S)([-/*x])\s*(\d+)(?!\S)' # SYMBOL num
|
|
)
|
|
text = re.sub(ambiguous_pattern, _repl_ambiguous, text)
|
|
text = set_formatted_number(text, lang, lang_iso1, is_num2words_compat)
|
|
return text
|
|
|
|
def roman2number(text:str)->str:
|
|
|
|
def _is_valid_roman(s:str)->bool:
|
|
return bool(valid_roman.fullmatch(s))
|
|
|
|
def _to_int(s:str)->str:
|
|
s = s.upper()
|
|
i = 0
|
|
result = 0
|
|
while i < len(s):
|
|
for roman, value in roman_numbers_tuples:
|
|
if s[i:i + len(roman)] == roman:
|
|
result += value
|
|
i += len(roman)
|
|
break
|
|
else:
|
|
return s
|
|
return str(result)
|
|
|
|
def _repl_heading(m:re.Match)->str:
|
|
roman = m.group(1)
|
|
if not _is_valid_roman(roman):
|
|
return m.group(0)
|
|
return f"{_to_int(roman)}{m.group(2)}{m.group(3)}"
|
|
|
|
def _repl_standalone(m:re.Match)->str:
|
|
roman = m.group(1)
|
|
if not _is_valid_roman(roman):
|
|
return m.group(0)
|
|
return f"{_to_int(roman)}{m.group(2)}"
|
|
|
|
def _repl_word(m:re.Match)->str:
|
|
roman = m.group(1)
|
|
if not _is_valid_roman(roman):
|
|
return m.group(0)
|
|
return _to_int(roman)
|
|
|
|
def _repl_chapter_single(m:re.Match)->str:
|
|
word = m.group(1)
|
|
roman = m.group(2)
|
|
if not _is_valid_roman(roman):
|
|
return m.group(0)
|
|
return f'{word} {_to_int(roman)}'
|
|
|
|
valid_roman = re.compile(
|
|
r'^(?=.)M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$',
|
|
re.IGNORECASE
|
|
)
|
|
chapter_words = sorted(
|
|
{w for words in chapter_word_mapping.values() for w in words},
|
|
key=len,
|
|
reverse=True
|
|
)
|
|
chapter_words_re = re.compile(
|
|
rf'\b({"|".join(map(re.escape, chapter_words))})\s+([IVXLCDM]+)\b',
|
|
re.IGNORECASE | re.UNICODE
|
|
)
|
|
text = re.sub(
|
|
r'^(?:\s*)([IVXLCDM]+)([.-])(\s+)',
|
|
_repl_heading,
|
|
text,
|
|
flags=re.MULTILINE
|
|
)
|
|
text = re.sub(
|
|
r'^(?:\s*)([IVXLCDM]+)([.-])(?:\s*)$',
|
|
_repl_standalone,
|
|
text,
|
|
flags=re.MULTILINE
|
|
)
|
|
text = chapter_words_re.sub(_repl_chapter_single, text)
|
|
text = re.sub(
|
|
r'(?<!\S)([IVXLCDM]{2,})(?!\S)',
|
|
_repl_word,
|
|
text
|
|
)
|
|
return text
|
|
|
|
def is_latin(s:str)->bool:
|
|
return all((u'a' <= ch.lower() <= 'z') or ch.isdigit() or not ch.isalpha() for ch in s)
|
|
|
|
def foreign2latin(text:str, base_lang:str)->str:
|
|
|
|
def _script_of(word:str)->str:
|
|
for ch in word:
|
|
if ch.isalpha():
|
|
name = unicodedata.name(ch, '')
|
|
if 'CYRILLIC' in name:
|
|
return 'cyrillic'
|
|
if 'LATIN' in name:
|
|
return 'latin'
|
|
if 'ARABIC' in name:
|
|
return 'arabic'
|
|
if 'HANGUL' in name:
|
|
return 'hangul'
|
|
if 'HIRAGANA' in name or 'KATAKANA' in name:
|
|
return 'japanese'
|
|
if 'CJK' in name or 'IDEOGRAPH' in name:
|
|
return 'chinese'
|
|
return 'unknown'
|
|
|
|
def _romanize(word:str)->str:
|
|
scr = _script_of(word)
|
|
if scr == 'latin':
|
|
return word
|
|
try:
|
|
if scr == 'chinese':
|
|
return ''.join(x[0] for x in pinyin(word, style=Style.NORMAL))
|
|
if scr == 'japanese':
|
|
k = pykakasi.kakasi()
|
|
k.setMode('H', 'a')
|
|
k.setMode('K', 'a')
|
|
k.setMode('J', 'a')
|
|
k.setMode('r', 'Hepburn')
|
|
return k.getConverter().do(word)
|
|
if scr == 'hangul':
|
|
return unidecode(word)
|
|
if scr == 'arabic':
|
|
return unidecode(phonemize(word, language='ar', backend='espeak'))
|
|
if scr == 'cyrillic':
|
|
return unidecode(phonemize(word, language='ru', backend='espeak'))
|
|
return unidecode(word)
|
|
except Exception:
|
|
return unidecode(word)
|
|
|
|
# Protect ALL SML tags using the global grammar
|
|
protected:dict[str, str] = {}
|
|
for i, m in enumerate(SML_TAG_PATTERN.finditer(text)):
|
|
key:str = f'__TTS_MARKER_{i}__'
|
|
protected[key] = m.group(0)
|
|
text = text.replace(m.group(0), key)
|
|
tokens:list[str] = re.findall(r"\w+|[^\w\s]", text, re.UNICODE)
|
|
buf:list[str] = []
|
|
for t in tokens:
|
|
if t in protected:
|
|
buf.append(t)
|
|
elif re.match(r"^\w+$", t):
|
|
buf.append(_romanize(t))
|
|
else:
|
|
buf.append(t)
|
|
out:str = ''
|
|
for i, t in enumerate(buf):
|
|
if i == 0:
|
|
out += t
|
|
else:
|
|
if re.match(r"^\w+$", buf[i - 1]) and re.match(r"^\w+$", t):
|
|
out += ' ' + t
|
|
else:
|
|
out += t
|
|
for k, v in protected.items():
|
|
out = out.replace(k, v)
|
|
return out
|
|
|
|
def normalize_sml_tags(text:str)->tuple[bool, str]:
|
|
out = []
|
|
stack = []
|
|
last = 0
|
|
for m in SML_TAG_PATTERN.finditer(text):
|
|
start, end = m.span()
|
|
out.append(text[last:start])
|
|
tag = m.group("tag")
|
|
close = bool(m.group("close"))
|
|
value = m.group("value")
|
|
info = TTS_SML.get(tag)
|
|
if not info:
|
|
out.append(m.group(0))
|
|
last = end
|
|
continue
|
|
if info.get("paired"):
|
|
if close:
|
|
if not stack or stack[-1] != tag:
|
|
error = f'normalize_sml_tags() error: unmatched closing tag [/{tag}]'
|
|
return False, error
|
|
stack.pop()
|
|
out.append(f"[/{tag}]")
|
|
else:
|
|
stack.append(tag)
|
|
if value is not None:
|
|
out.append(f"[{tag}:{value.strip()}]")
|
|
else:
|
|
error = f'normalize_sml_tags() error: paired tag [{tag}] requires a value'
|
|
return False, error
|
|
else:
|
|
if close:
|
|
error = f'normalize_sml_tags() error: non-paired tag [/{tag}] is invalid'
|
|
return False, error
|
|
out.append(info['static'])
|
|
last = end
|
|
out.append(text[last:])
|
|
if stack:
|
|
error = f"normalize_sml_tags() error: unclosed tag(s): {', '.join(stack)}"
|
|
return False, error
|
|
return True, ''.join(out)
|
|
|
|
def escape_sml(text:str)->tuple[str, list[str]]:
|
|
sml_blocks:list[str] = []
|
|
|
|
def _replace(m:re.Match[str])->str:
|
|
sml_blocks.append(m.group(0))
|
|
return chr(sml_escape_tag + len(sml_blocks) - 1)
|
|
|
|
return SML_TAG_PATTERN.sub(_replace, text), sml_blocks
|
|
|
|
def restore_sml(text:str, sml_blocks:list[str])->str:
|
|
for i, block in enumerate(sml_blocks):
|
|
text = text.replace(chr(sml_escape_tag + i), block)
|
|
return text
|
|
|
|
def sml_token(tag:str, value:str|None=None, close:bool=False)->str:
|
|
if close:
|
|
return f"[/{tag}]"
|
|
if value is not None:
|
|
return f"[{tag}:{value}]"
|
|
return f"[{tag}]"
|
|
|
|
def normalize_text(text:str, lang:str, lang_iso1:str, tts_engine:str)->str:
|
|
|
|
def _replace(match:re.Match)->str:
|
|
token = match.group(1)
|
|
for k, expansion in mapping.items():
|
|
if token.lower() == k.lower():
|
|
return expansion
|
|
return token # fallback
|
|
|
|
# Remove emojis
|
|
emoji_pattern = re.compile(f"[{''.join(emojis_list)}]+", flags=re.UNICODE)
|
|
emoji_pattern.sub('', text)
|
|
if lang in abbreviations_mapping:
|
|
mapping = abbreviations_mapping[lang]
|
|
# Sort keys by descending length so longer ones match first
|
|
keys = sorted(mapping.keys(), key=len, reverse=True)
|
|
# Build a regex that only matches whole “words” (tokens) exactly
|
|
pattern = re.compile(
|
|
r'(?<!\w)(' + '|'.join(re.escape(k) for k in keys) + r')(?!\w)',
|
|
flags=re.IGNORECASE
|
|
)
|
|
text = pattern.sub(_replace, text)
|
|
# This regex matches sequences like a., c.i.a., f.d.a., m.c., etc…
|
|
pattern = re.compile(r'\b(?:[a-zA-Z]\.){1,}[a-zA-Z]?\b\.?')
|
|
# uppercase acronyms
|
|
text = re.sub(r'\b(?:[a-zA-Z]\.){1,}[a-zA-Z]?\b\.?', lambda m: m.group().replace('.', '').upper(), text)
|
|
# romanize foreign words
|
|
if language_mapping[lang]['script'] == 'latin':
|
|
text = foreign2latin(text, lang)
|
|
# Replace multiple newlines ("\n\n", "\r\r", "\n\r", etc.) with a [pause] 1.4sec
|
|
pattern = r'(?:\r\n|\r|\n){2,}'
|
|
text = re.sub(pattern, f" {sml_token('pause')} ", text)
|
|
# Replace single newlines ("\n" or "\r") with spaces
|
|
text = re.sub(r'\r\n|\r|\n', ' ', text)
|
|
# Replace punctuations causing hallucinations
|
|
pattern = f"[{''.join(map(re.escape, punctuation_switch.keys()))}]"
|
|
text = re.sub(pattern, lambda match: punctuation_switch.get(match.group(), match.group()), text)
|
|
# remove unwanted chars
|
|
chars_remove_table = str.maketrans({ch: ' ' for ch in chars_remove})
|
|
text = text.translate(chars_remove_table)
|
|
# replace double quotes by a comma if no punctuation precedes it
|
|
text = re.sub(r'\s*"\s*', '"', text)
|
|
text = re.sub(r'(?<=[\p{L}\p{N}])"(?=[\p{L}\p{N}]|$)', ', ', text)
|
|
text = re.sub(r'"', '', text)
|
|
# Replace multiple and spaces with single space
|
|
text = re.sub(r'\s+', ' ', text)
|
|
# Replace ok by 'Owkey'
|
|
text = re.sub(r'\bok\b', 'Okay', text, flags=re.IGNORECASE)
|
|
# Escape special characters in the punctuation list for regex
|
|
pattern = '|'.join(map(re.escape, punctuation_split_hard_set))
|
|
# Reduce multiple consecutive punctuations hard
|
|
text = re.sub(rf'(\s*({pattern})\s*)+', r'\2 ', text).strip()
|
|
# Escape special characters in the punctuation list for regex
|
|
pattern = '|'.join(map(re.escape, punctuation_split_soft_set))
|
|
# Reduce multiple consecutive punctuations soft
|
|
text = re.sub(rf'(\s*({pattern})\s*)+', r'\2 ', text).strip()
|
|
# Pattern 1: Add a space between UTF-8 characters and numbers
|
|
text = re.sub(r'(?<=[\p{L}])(?=\d)|(?<=\d)(?=[\p{L}])', ' ', text)
|
|
# Replace special chars with words
|
|
specialchars = specialchars_mapping.get(lang, specialchars_mapping.get(default_language_code, specialchars_mapping['eng']))
|
|
specialchars_table = {ord(char): f" {word} " for char, word in specialchars.items()}
|
|
text = text.translate(specialchars_table)
|
|
text = ' '.join(text.split())
|
|
return text
|
|
|
|
def block_hash(block: dict) -> str:
|
|
return hashlib.sha1(
|
|
'|'.join((
|
|
(block.get('text') or '').strip(),
|
|
block.get('voice') or '',
|
|
block.get('tts_engine') or TTS_ENGINES['XTTS'],
|
|
block.get('fine_tuned') or 'internal',
|
|
json.dumps(block.get('sentences') or [], ensure_ascii=False),
|
|
)).encode('utf-8')
|
|
).hexdigest()
|
|
|
|
def convert_chapters2audio(session_id:str)->bool:
|
|
|
|
def _reset_chapter_file(block_id:str)->None:
|
|
ch_file = os.path.join(session['chapters_dir'], f'{block_id}.{default_audio_proc_format}')
|
|
if os.path.exists(ch_file):
|
|
os.unlink(ch_file)
|
|
block_dir = os.path.join(session['sentences_dir'], block_id)
|
|
if os.path.isdir(block_dir):
|
|
shutil.rmtree(block_dir)
|
|
|
|
def _check_block_sentences(block_id:str, sentences:list)->set:
|
|
block_dir = os.path.join(session['sentences_dir'], block_id)
|
|
missing = set()
|
|
for j in valid_idx:
|
|
is_sml = bool(SML_TAG_PATTERN.fullmatch(sentences[j]))
|
|
if (not is_sml) or (j == last_idx):
|
|
sentence_file = os.path.join(block_dir, f'{j}.{default_audio_proc_format}')
|
|
if not os.path.exists(sentence_file):
|
|
missing.add(j)
|
|
return missing
|
|
|
|
def _count_sentences(sentences:list)->int:
|
|
return sum(1 for s in sentences if any(c.isalnum() for c in s.strip()))
|
|
|
|
session = context.get_session(session_id)
|
|
if not (session and session.get('id', False)):
|
|
return False
|
|
try:
|
|
if session['cancellation_requested']:
|
|
return False
|
|
print(f'*********** Session: {session_id} **************\n{session_info}')
|
|
tts_manager = TTSManager(session)
|
|
blocks_current = session['blocks_current']
|
|
blocks = blocks_current['blocks']
|
|
block_resume = blocks_current['block_resume']
|
|
sentence_resume = blocks_current['sentence_resume']
|
|
blocks_saved = session['blocks_saved']
|
|
prev_blocks_list = blocks_saved.get('blocks', [])
|
|
prev_blocks = {b['id']: b for b in prev_blocks_list} if isinstance(prev_blocks_list, list) else prev_blocks_list
|
|
xtts_languages = default_engine_settings[TTS_ENGINES['XTTS']].get('languages', {})
|
|
_lang = session['language']
|
|
if session.get('translate_enabled') and session.get('translate'):
|
|
_lang = session['translate']
|
|
if _lang != 'eng' and _lang in xtts_languages:
|
|
is_voice_changed = False
|
|
voice_cache = {}
|
|
for block in blocks:
|
|
old_voice = block.get('voice')
|
|
if old_voice in voice_cache:
|
|
new_voice = voice_cache[old_voice]
|
|
else:
|
|
if old_voice is None:
|
|
new_voice = None
|
|
else:
|
|
new_voice, error = tts_manager.set_voice(old_voice)
|
|
if new_voice is None and error is not None:
|
|
show_alert(session_id, {'type': 'warning', 'msg': error})
|
|
return False
|
|
voice_cache[old_voice] = new_voice
|
|
if new_voice != old_voice:
|
|
is_voice_changed = True
|
|
block['voice'] = new_voice
|
|
if blocks_saved:
|
|
if block['id'] in prev_blocks:
|
|
prev_blocks[block['id']]['voice'] = new_voice
|
|
if is_voice_changed:
|
|
if blocks_saved:
|
|
blocks_saved['blocks'] = list(prev_blocks.values())
|
|
session['blocks_saved'] = blocks_saved
|
|
save_json_blocks(session_id, 'blocks_saved')
|
|
blocks_current['blocks'] = blocks
|
|
session['blocks_current'] = blocks_current
|
|
save_db_blocks(session_id)
|
|
total_chapters = sum(1 for b in blocks if b['keep'] and b['text'].strip())
|
|
if total_chapters == 0:
|
|
show_alert(session_id, {'type': 'warning', 'msg': 'No chapters found!'})
|
|
return False
|
|
total_sentences = sum(_count_sentences(b['sentences']) for b in blocks if b['keep'] and b['text'].strip())
|
|
if total_sentences == 0:
|
|
show_alert(session_id, {'type': 'warning', 'msg': 'No sentences found!'})
|
|
return False
|
|
if not session['ebook']:
|
|
return False
|
|
ebook_name = Path(session['ebook']).name
|
|
chapters_dir = session['chapters_dir']
|
|
sentences_dir = session['sentences_dir']
|
|
global_sent = 0
|
|
ch_num = 0
|
|
last_save_time = time.monotonic()
|
|
baseline_initialized = False
|
|
msg = (f'---------<br/>'
|
|
f"{session['filename_noext']}<br/>"
|
|
f"A total of {total_chapters} {'block' if total_chapters <= 1 else 'blocks'} "
|
|
f"and {total_sentences} {'sentence' if total_sentences <= 1 else 'sentences'}."
|
|
f'<br/>---------')
|
|
show_alert(session_id, {'type': 'info', 'msg': msg})
|
|
with tqdm(total=total_sentences, desc='0.00%', bar_format='{desc}: {n_fmt}/{total_fmt} ', unit='step', initial=0) as t:
|
|
for x, block in enumerate(blocks):
|
|
if not (block['keep'] and block['text'].strip()):
|
|
continue
|
|
if session['cancellation_requested']:
|
|
return False
|
|
ch_num += 1
|
|
block_id = block['id']
|
|
sentences = block['sentences']
|
|
block_len = len(sentences)
|
|
valid_idx = {i for i,s in enumerate(sentences) if any(c.isalnum() for c in s.strip())}
|
|
last_idx = block_len - 1
|
|
sent_start = global_sent
|
|
current_hash = block_hash(block)
|
|
block_ref = prev_blocks.get(block_id)
|
|
hash_ref = block_hash(block_ref) if block_ref else None
|
|
block_changed = block_ref is not None and hash_ref != current_hash
|
|
missing_sentences = set()
|
|
start_sentence = 0
|
|
chapter_audio_file = os.path.join(chapters_dir, f'{block_id}.{default_audio_proc_format}')
|
|
block_dir = os.path.join(sentences_dir, block_id)
|
|
if x < block_resume and not block_changed:
|
|
if not os.path.exists(chapter_audio_file):
|
|
show_alert(session_id, {'type': 'warning', 'msg': f'Block {x} chapter audio missing, reconverting entire block…'})
|
|
_reset_chapter_file(block_id)
|
|
else:
|
|
missing_sentences = _check_block_sentences(block_id, sentences)
|
|
if not missing_sentences:
|
|
print(f'Chapter {ch_num} (block {x}) — has all sentences')
|
|
cnt = len(valid_idx)
|
|
global_sent += cnt
|
|
t.update(cnt)
|
|
continue
|
|
show_alert(session_id, {'type': 'warning', 'msg': f'Block {x} has {len(missing_sentences)} missing audio files, reconverting…'})
|
|
_reset_chapter_file(block_id)
|
|
elif block_changed and x <= block_resume:
|
|
show_alert(session_id, {'type': 'info', 'msg': f'Chapter {ch_num} (block {x}) — changed, reconverting'})
|
|
_reset_chapter_file(block_id)
|
|
elif x == block_resume and not block_changed:
|
|
if sentence_resume == 0 and os.path.isdir(block_dir):
|
|
shutil.rmtree(block_dir)
|
|
start_sentence = sentence_resume
|
|
show_alert(session_id, {'type': 'info', 'msg': f'Chapter {ch_num} (block {x}) containing {block_len} sentences…'})
|
|
os.makedirs(block_dir, exist_ok=True)
|
|
blocks_current['block_resume'] = x
|
|
blocks_current['sentence_resume'] = start_sentence
|
|
session['blocks_current'] = blocks_current
|
|
save_db_stamp(session_id)
|
|
converted = False
|
|
block_voice = block.get('voice') or session.get('voice')
|
|
for j in range(block_len):
|
|
if session['cancellation_requested']:
|
|
msg = 'Conversion Cancelled'
|
|
return False
|
|
sentence = sentences[j].strip()
|
|
if j in valid_idx:
|
|
if j >= start_sentence or j in missing_sentences:
|
|
if j == start_sentence and start_sentence > 0:
|
|
show_alert(session_id, {'type': 'info', 'msg': f'*** Resuming from sentence {global_sent} ***'})
|
|
sentence_file = os.path.join(block_dir, f'{j}.{default_audio_proc_format}')
|
|
run, error = tts_manager.convert_sentence2audio(sentence_file, sentence, block_voice=block_voice)
|
|
if not run:
|
|
show_alert(session_id, {'type': 'warning', 'msg': error})
|
|
return False
|
|
converted = True
|
|
blocks_current['sentence_resume'] = j
|
|
now = time.monotonic()
|
|
if not baseline_initialized:
|
|
session['blocks_current'] = blocks_current
|
|
session['blocks_saved'] = copy.deepcopy(blocks_current)
|
|
save_json_blocks(session_id, 'blocks_saved')
|
|
baseline_initialized = True
|
|
elif now - last_save_time >= 5:
|
|
session['blocks_current'] = blocks_current
|
|
save_db_stamp(session_id)
|
|
last_save_time = now
|
|
global_sent += 1
|
|
total_progress = (t.n + 1) / total_sentences
|
|
if session['is_gui_process']:
|
|
progress_bar(progress=total_progress, desc=f'{ebook_name} - {sentence}')
|
|
t.set_description(f'{total_progress * 100:.2f}%')
|
|
print(f' : {sentence}')
|
|
t.update(1)
|
|
sent_end = global_sent - 1
|
|
show_alert(session_id, {'type': 'info', 'msg': f'End of Chapter {ch_num} (block {x})'})
|
|
if converted or block_changed or missing_sentences:
|
|
show_alert(session_id, {'type': 'info', 'msg': f'Combining chapter {ch_num} (block {x}) to audio, sentence {sent_start} to {sent_end}'})
|
|
session['blocks_current'] = blocks_current
|
|
save_db_stamp(session_id)
|
|
last_save_time = time.monotonic()
|
|
if not combine_audio_sentences(session_id, chapter_audio_file, block_id, block_len):
|
|
show_alert(session_id, {'type': 'warning', 'msg': 'combine_audio_sentences() failed!'})
|
|
return False
|
|
#blocks_current['block_resume'] = 0
|
|
#blocks_current['sentence_resume'] = 0
|
|
session['blocks_current'] = blocks_current
|
|
save_db_stamp(session_id)
|
|
session['blocks_saved'] = copy.deepcopy(blocks_current)
|
|
save_json_blocks(session_id, 'blocks_saved')
|
|
return True
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
exception_alert(session_id, f'convert_chapters2audio() error: {e}')
|
|
return False
|
|
|
|
def combine_audio_sentences(session_id:str, file:str, block_id:str, sentence_count:int)->bool:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not session or not session.get('id', False):
|
|
error = 'Session expired!'
|
|
print(error)
|
|
return False
|
|
if sentence_count == 0:
|
|
error = f'No sentences to combine for block {block_id}.'
|
|
print(error)
|
|
return False
|
|
block_dir = Path(session['sentences_dir']) / block_id
|
|
ext = default_audio_proc_format
|
|
selected_files = []
|
|
missing = []
|
|
for i in range(sentence_count):
|
|
path = block_dir / f'{i}.{ext}'
|
|
if path.is_file():
|
|
selected_files.append(path)
|
|
else:
|
|
missing.append(i)
|
|
if missing:
|
|
error = f'Missing sentence files in block {block_id}: {missing}'
|
|
print(error)
|
|
return False
|
|
concat_dir = session['process_dir']
|
|
concat_list = os.path.join(concat_dir, 'concat_list_sentences.txt')
|
|
with open(concat_list, 'w') as f:
|
|
for path in selected_files:
|
|
if session['cancellation_requested']:
|
|
return False
|
|
f.write(f"file '{path.as_posix()}'\n")
|
|
result = assemble_audio_chunks(concat_list, file, session['is_gui_process'])
|
|
if not result:
|
|
error = 'combine_audio_sentences() FFmpeg concat failed.'
|
|
print(error)
|
|
return False
|
|
msg = f'********* Combined block audio file saved in {file}'
|
|
print(msg)
|
|
return True
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
return False
|
|
|
|
def combine_audio_chapters(session_id:str)->list[str]|None:
|
|
|
|
def _on_progress(p:float, desc:str)->None:
|
|
if is_gui_process:
|
|
progress_bar(p / 100.0, desc=desc)
|
|
|
|
def _generate_ffmpeg_metadata(part_chapters:list[tuple[str,str]], output_metadata_path:str, default_audio_proc_format:str, part_num:int=None)->str|bool:
|
|
try:
|
|
out_fmt = session['output_format']
|
|
is_mp4_like = out_fmt in ['mp4', 'm4a', 'm4b', 'mov']
|
|
is_vorbis = out_fmt in ['ogg', 'webm']
|
|
is_mp3 = out_fmt == 'mp3'
|
|
def tag(key):
|
|
return key.upper() if is_vorbis else key
|
|
ffmpeg_metadata = ';FFMETADATA1\n'
|
|
if session['metadata'].get('title'):
|
|
ffmpeg_metadata += f"{tag('title')}={session['metadata']['title']}\n"
|
|
if session['metadata'].get('creator'):
|
|
ffmpeg_metadata += f"{tag('artist')}={session['metadata']['creator']}\n"
|
|
if session['metadata'].get('language'):
|
|
ffmpeg_metadata += f"{tag('language')}={session['metadata']['language']}\n"
|
|
if session['metadata'].get('description'):
|
|
ffmpeg_metadata += f"{tag('description')}={session['metadata']['description']}\n"
|
|
if session['metadata'].get('publisher') and (is_mp4_like or is_mp3):
|
|
ffmpeg_metadata += f"{tag('publisher')}={session['metadata']['publisher']}\n"
|
|
if session['metadata'].get('published'):
|
|
try:
|
|
if '.' in session['metadata']['published']:
|
|
year = datetime.strptime(session['metadata']['published'], '%Y-%m-%dT%H:%M:%S.%f%z').year
|
|
else:
|
|
year = datetime.strptime(session['metadata']['published'], '%Y-%m-%dT%H:%M:%S%z').year
|
|
except Exception:
|
|
year = datetime.now().year
|
|
else:
|
|
year = datetime.now().year
|
|
if is_vorbis:
|
|
ffmpeg_metadata += f"{tag('date')}={year}\n"
|
|
else:
|
|
ffmpeg_metadata += f"{tag('year')}={year}\n"
|
|
if session['metadata'].get('identifiers') and isinstance(session['metadata']['identifiers'], dict):
|
|
if is_mp3 or is_mp4_like:
|
|
isbn = session['metadata']['identifiers'].get('isbn')
|
|
if isbn:
|
|
ffmpeg_metadata += f"{tag('isbn')}={isbn}\n"
|
|
asin = session['metadata']['identifiers'].get('mobi-asin')
|
|
if asin:
|
|
ffmpeg_metadata += f"{tag('asin')}={asin}\n"
|
|
start_time = 0
|
|
total = len(part_chapters)
|
|
progress_desc = f'Metadata Part {part_num}' if part_num is not None else 'Metadata'
|
|
iterable = part_chapters if is_gui_process else tqdm(part_chapters, desc=progress_desc, total=total, unit='ch')
|
|
for i, (filename, chapter_title) in enumerate(iterable):
|
|
if session['cancellation_requested']:
|
|
return False
|
|
filepath = os.path.join(session['chapters_dir'], filename)
|
|
duration_ms = len(AudioSegment.from_file(filepath, format=default_audio_proc_format))
|
|
clean_title = re.sub(r'(^#)|[=\\]|(-$)', lambda m: '\\' + (m.group(1) or m.group(0)), sanitize_meta_chapter_title(chapter_title))
|
|
ffmpeg_metadata += '[CHAPTER]\nTIMEBASE=1/1000\n'
|
|
ffmpeg_metadata += f'START={start_time}\nEND={start_time + duration_ms}\n'
|
|
ffmpeg_metadata += f"{tag('title')}={clean_title}\n"
|
|
start_time += duration_ms
|
|
if is_gui_process:
|
|
_on_progress((((i + 1) / total) * 100.0), progress_desc)
|
|
with open(output_metadata_path, 'w', encoding='utf-8') as f:
|
|
f.write(ffmpeg_metadata)
|
|
return output_metadata_path
|
|
except Exception as e:
|
|
error = f'_generate_ffmpeg_metadata() Error: {e}'
|
|
print(error)
|
|
return False
|
|
|
|
def _export_audio(combined_audio:str, metadata_file:str, final_file:str, block_indices:set=None, part_num:int=None)->bool:
|
|
try:
|
|
if session['cancellation_requested']:
|
|
return False
|
|
ffprobe_cmd = [
|
|
shutil.which('ffprobe'), '-v', 'error', '-threads', '0', '-select_streams', 'a:0',
|
|
'-show_entries', 'stream=codec_name,sample_rate,sample_fmt',
|
|
'-of', 'default=nokey=1:noprint_wrappers=1', combined_audio
|
|
]
|
|
probe = subprocess.run(ffprobe_cmd, capture_output=True, text=True)
|
|
if probe.returncode != 0:
|
|
error = f'ffprobe failed for {combined_audio}: {probe.stderr.strip()}'
|
|
print(error)
|
|
return False
|
|
codec_info = probe.stdout.strip().splitlines()
|
|
input_codec = codec_info[0] if len(codec_info) > 0 else None
|
|
input_rate = codec_info[1] if len(codec_info) > 1 else None
|
|
cmd = [shutil.which('ffmpeg'), '-hide_banner', '-nostats', '-hwaccel', 'auto', '-thread_queue_size', '1024', '-i', combined_audio]
|
|
target_codec, target_rate = None, None
|
|
if session['output_format'] == 'wav':
|
|
target_codec = 'pcm_s16le'
|
|
target_rate = '44100'
|
|
cmd += ['-map', '0:a', '-ar', target_rate, '-sample_fmt', 's16']
|
|
elif session['output_format'] == 'aac':
|
|
target_codec = 'aac'
|
|
target_rate = '44100'
|
|
cmd += ['-c:a', 'aac', '-b:a', '192k', '-ar', target_rate, '-movflags', '+faststart']
|
|
elif session['output_format'] == 'flac':
|
|
target_codec = 'flac'
|
|
target_rate = '44100'
|
|
cmd += ['-c:a', 'flac', '-compression_level', '5', '-ar', target_rate]
|
|
else:
|
|
cmd += ['-f', 'ffmetadata', '-i', metadata_file, '-map', '0:a']
|
|
if session['output_format'] in ['m4a', 'm4b', 'mp4', 'mov']:
|
|
target_codec = 'aac'
|
|
target_rate = '44100'
|
|
cmd += ['-c:a', 'aac', '-b:a', '192k', '-ar', target_rate, '-movflags', '+faststart+use_metadata_tags']
|
|
elif session['output_format'] == 'mp3':
|
|
target_codec = 'mp3'
|
|
target_rate = '44100'
|
|
cmd += ['-c:a', 'libmp3lame', '-b:a', '192k', '-ar', target_rate]
|
|
elif session['output_format'] == 'webm':
|
|
target_codec = 'opus'
|
|
target_rate = '48000'
|
|
cmd += ['-c:a', 'libopus', '-b:a', '192k', '-ar', target_rate]
|
|
elif session['output_format'] == 'ogg':
|
|
target_codec = 'opus'
|
|
target_rate = '48000'
|
|
cmd += ['-c:a', 'libopus', '-compression_level', '0', '-b:a', '192k', '-ar', target_rate]
|
|
cmd += ['-map_metadata', '1']
|
|
if session['output_channel'] == 'stereo':
|
|
cmd += ['-ac', '2']
|
|
else:
|
|
cmd += ['-ac', '1']
|
|
if input_codec == target_codec and input_rate == target_rate:
|
|
cmd = [
|
|
shutil.which('ffmpeg'), '-hide_banner', '-nostats', '-hwaccel', 'auto', '-thread_queue_size', '1024', '-i', combined_audio,
|
|
'-threads', '0', '-f', 'ffmetadata', '-i', metadata_file,
|
|
'-map', '0:a', '-map_metadata', '1', '-c', 'copy',
|
|
'-progress', 'pipe:2',
|
|
'-y', final_file
|
|
]
|
|
else:
|
|
cmd += [
|
|
'-filter_threads', '0',
|
|
'-filter_complex_threads', '0',
|
|
'-af', 'dynaudnorm=f=150:g=15,afftdn=nf=-70',
|
|
'-threads', '0',
|
|
'-progress', 'pipe:2',
|
|
'-y', final_file
|
|
]
|
|
progress_desc = f'Export Part {part_num}' if part_num is not None else 'Export'
|
|
proc_pipe = SubprocessPipe(cmd, is_gui_process=is_gui_process, total_duration=get_audio_duration(combined_audio), msg='Export', on_progress=lambda p: _on_progress(p, progress_desc))
|
|
if not proc_pipe.result:
|
|
error = f'ffmpeg export failed for {final_file}'
|
|
print(error)
|
|
return False
|
|
if not (os.path.exists(final_file) and os.path.getsize(final_file) > 0):
|
|
error = f'{Path(final_file).name} is corrupted or does not exist'
|
|
print(error)
|
|
return False
|
|
if session['output_format'] in ['mp3', 'm4a', 'm4b', 'mp4'] and session['cover'] is not None:
|
|
cover_path = session['cover']
|
|
msg = f'Adding cover {cover_path} into the final audiobook file…'
|
|
print(msg)
|
|
audio = None
|
|
if session['output_format'] == 'mp3':
|
|
from mutagen.mp3 import MP3
|
|
from mutagen.id3 import ID3, APIC, error as id3_error
|
|
audio = MP3(final_file, ID3=ID3)
|
|
try:
|
|
audio.add_tags()
|
|
except id3_error:
|
|
pass
|
|
with open(cover_path, 'rb') as img:
|
|
audio.tags.add(APIC(encoding=3, mime='image/jpeg', type=3, desc='Cover', data=img.read()))
|
|
elif session['output_format'] in ['mp4', 'm4a', 'm4b']:
|
|
from mutagen.mp4 import MP4, MP4Cover
|
|
audio = MP4(final_file)
|
|
with open(cover_path, 'rb') as f:
|
|
cover_data = f.read()
|
|
audio['covr'] = [MP4Cover(cover_data, imageformat=MP4Cover.FORMAT_JPEG)]
|
|
if audio is not None:
|
|
audio.save()
|
|
final_vtt = os.path.join(session['audiobooks_dir'], f'{Path(final_file).stem}.vtt')
|
|
vtt_built, error = build_vtt_file(session, vtt_path=final_vtt, block_indices=block_indices)
|
|
if not vtt_built:
|
|
error = f'build_vtt_file() error: {error}'
|
|
print(error)
|
|
return False
|
|
return True
|
|
except Exception as e:
|
|
error = f'Export failed: {e}'
|
|
print(error)
|
|
return False
|
|
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not (session and session.get('id', False)):
|
|
return None
|
|
is_gui_process = session['is_gui_process']
|
|
chapter_files = []
|
|
chapter_titles = []
|
|
chapter_positions = []
|
|
for x, block in enumerate(session['blocks_current']['blocks']):
|
|
if not (block['keep'] and block['text'].strip()):
|
|
continue
|
|
if not block.get('sentences'):
|
|
error = f"Block {x} (id {block['id']}) has no sentences but is marked keep"
|
|
print(error)
|
|
return None
|
|
block_id = block['id']
|
|
fname = f'{block_id}.{default_audio_proc_format}'
|
|
fpath = os.path.join(session['chapters_dir'], fname)
|
|
if not os.path.exists(fpath):
|
|
error = f'Missing chapter audio for block {x} (id {block_id}): {fpath}'
|
|
print(error)
|
|
return None
|
|
chapter_files.append(fname)
|
|
chapter_titles.append(block['sentences'][0])
|
|
chapter_positions.append(x)
|
|
if len(chapter_files) == 0:
|
|
print('No block files exist!')
|
|
return None
|
|
chunks_size = 892
|
|
total_duration = 0.0
|
|
durations = []
|
|
for i in range(0, len(chapter_files), chunks_size):
|
|
filepaths = [
|
|
os.path.join(session['chapters_dir'], f)
|
|
for f in chapter_files[i:i + chunks_size]
|
|
]
|
|
durations_dict = get_audiolist_duration(filepaths)
|
|
for path in filepaths:
|
|
dur = durations_dict.get(path, 0.0)
|
|
durations.append(dur)
|
|
total_duration += dur
|
|
if len(durations) != len(chapter_files):
|
|
error = f'Duration count mismatch: {len(durations)} durations vs {len(chapter_files)} chapter files'
|
|
print(error)
|
|
return None
|
|
exported_files = []
|
|
concat_dir = session['process_dir']
|
|
if session.get('output_split'):
|
|
part_files = []
|
|
part_chapter_indices = []
|
|
cur_part = []
|
|
cur_indices = []
|
|
cur_duration = 0
|
|
max_part_duration = int(session['output_split_hours']) * 3600
|
|
for idx, (file, dur) in enumerate(zip(chapter_files, durations)):
|
|
if session['cancellation_requested']:
|
|
return None
|
|
if cur_part and (cur_duration + dur > max_part_duration):
|
|
part_files.append(cur_part)
|
|
part_chapter_indices.append(cur_indices)
|
|
cur_part = []
|
|
cur_indices = []
|
|
cur_duration = 0
|
|
cur_part.append(file)
|
|
cur_indices.append(idx)
|
|
cur_duration += dur
|
|
if cur_part:
|
|
part_files.append(cur_part)
|
|
part_chapter_indices.append(cur_indices)
|
|
pad_width = len(str(len(part_files)))
|
|
is_multi_part = len(part_files) > 1
|
|
for part_idx, (part_file_list, indices) in enumerate(zip(part_files, part_chapter_indices)):
|
|
concat_list = os.path.join(concat_dir, f'concat_list_chapters_{part_idx+1:0{pad_width}d}.txt')
|
|
with open(concat_list, 'w') as f:
|
|
for file in part_file_list:
|
|
if session['cancellation_requested']:
|
|
return None
|
|
path = Path(session['chapters_dir']) / file
|
|
f.write(f"file '{path.as_posix()}'\n")
|
|
merged_audio = Path(session['process_dir']) / f"{get_sanitized(session['metadata']['title'])}_part{part_idx+1:0{pad_width}d}.{default_audio_proc_format}"
|
|
result = assemble_audio_chunks(concat_list, merged_audio, is_gui_process)
|
|
if not result:
|
|
error = f'assemble_audio_chunks() Final merge failed for part {part_idx+1}.'
|
|
print(error)
|
|
return None
|
|
metadata_file = Path(session['process_dir']) / f'metadata_part{part_idx+1:0{pad_width}d}.txt'
|
|
part_chapters = [(chapter_files[i], chapter_titles[i]) for i in indices]
|
|
_generate_ffmpeg_metadata(part_chapters, str(metadata_file), default_audio_proc_format)
|
|
final_file = os.path.join(
|
|
session['audiobooks_dir'],
|
|
f"{Path(session['final_name']).stem}_part{part_idx+1:0{pad_width}d}.{session['output_format']}"
|
|
if is_multi_part else session['final_name']
|
|
)
|
|
block_indices = {chapter_positions[i] for i in indices} if is_multi_part else None
|
|
if _export_audio(merged_audio, metadata_file, final_file, block_indices=block_indices, part_num=part_idx+1):
|
|
exported_files.append(final_file)
|
|
else:
|
|
concat_list = os.path.join(concat_dir, 'concat_list_chapters_1.txt')
|
|
merged_audio = Path(session['process_dir']) / f"{get_sanitized(session['metadata']['title'])}.{default_audio_proc_format}"
|
|
with open(concat_list, 'w') as f:
|
|
for file in chapter_files:
|
|
if session['cancellation_requested']:
|
|
return None
|
|
path = Path(session['chapters_dir']) / file
|
|
f.write(f"file '{path.as_posix()}'\n")
|
|
result = assemble_audio_chunks(concat_list, merged_audio, is_gui_process)
|
|
if not result:
|
|
print(f'assemble_audio_chunks() Final merge failed for {merged_audio}.')
|
|
return None
|
|
metadata_file = os.path.join(session['process_dir'], 'metadata.txt')
|
|
chapters_zip = list(zip(chapter_files, chapter_titles))
|
|
_generate_ffmpeg_metadata(chapters_zip, metadata_file, default_audio_proc_format)
|
|
final_file = os.path.join(session['audiobooks_dir'], session['final_name'])
|
|
if _export_audio(merged_audio, metadata_file, final_file):
|
|
exported_files.append(final_file)
|
|
return exported_files if exported_files else None
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
return None
|
|
|
|
def assemble_audio_chunks(txt_file:str, out_file:str, is_gui_process:bool)->bool:
|
|
|
|
def _on_progress(p:float)->None:
|
|
if is_gui_process:
|
|
progress_bar(p / 100.0, desc='Assemble')
|
|
|
|
try:
|
|
total_duration = 0.0
|
|
filepaths = []
|
|
try:
|
|
with open(txt_file, 'r') as f:
|
|
for line in f:
|
|
if line.strip().startswith('file'):
|
|
file_path = (
|
|
line.strip()
|
|
.split('file ')[1]
|
|
.strip()
|
|
.strip("'")
|
|
.strip('"')
|
|
)
|
|
if os.path.exists(file_path):
|
|
filepaths.append(file_path)
|
|
durations = get_audiolist_duration(filepaths)
|
|
total_duration = sum(durations.values())
|
|
except Exception as e:
|
|
error = f'assemble_audio_chunks() open file {txt_file} Error: {e}'
|
|
print(error)
|
|
return False
|
|
ffmpeg = shutil.which('ffmpeg')
|
|
if not ffmpeg:
|
|
error = 'ffmpeg not found'
|
|
print(error)
|
|
return False
|
|
cmd = [
|
|
ffmpeg,
|
|
'-hide_banner',
|
|
'-nostats',
|
|
'-safe', '0',
|
|
'-f', 'concat',
|
|
'-i', txt_file,
|
|
'-c:a', default_audio_proc_format,
|
|
'-map_metadata', '-1',
|
|
'-threads', '0',
|
|
'-progress', 'pipe:2',
|
|
'-y', out_file
|
|
]
|
|
proc_pipe = SubprocessPipe(
|
|
cmd=cmd,
|
|
is_gui_process=is_gui_process,
|
|
total_duration=total_duration,
|
|
msg='Assemble',
|
|
on_progress=_on_progress
|
|
)
|
|
if proc_pipe.result and os.path.exists(out_file):
|
|
msg = f'Completed → {out_file}'
|
|
print(msg)
|
|
return True
|
|
else:
|
|
error = f'Failed (proc_pipe) → {out_file}'
|
|
print(error)
|
|
return False
|
|
except subprocess.CalledProcessError as e:
|
|
DependencyError(e)
|
|
return False
|
|
except Exception as e:
|
|
error = f'assemble_audio_chunks() Error: Failed to process {txt_file} → {out_file}: {e}'
|
|
print(error)
|
|
return False
|
|
|
|
def ellipsize_utf8_bytes(s:str, max_bytes:int, ellipsis:str='…')->str:
|
|
s = '' if s is None else str(s)
|
|
if max_bytes <= 0:
|
|
return ''
|
|
raw = s.encode('utf-8')
|
|
e = ellipsis.encode('utf-8')
|
|
if len(raw) <= max_bytes:
|
|
return s
|
|
if len(e) >= max_bytes:
|
|
# return as many bytes of the ellipsis as fit
|
|
return e[:max_bytes].decode('utf-8', errors='ignore')
|
|
budget = max_bytes - len(e)
|
|
out = bytearray()
|
|
for ch in s:
|
|
b = ch.encode('utf-8')
|
|
if len(out) + len(b) > budget:
|
|
break
|
|
out.extend(b)
|
|
return out.decode('utf-8') + ellipsis
|
|
|
|
def sanitize_meta_chapter_title(title:str, max_bytes:int=140)->str:
|
|
# avoid None and embedded NULs which some muxers accidentally keep
|
|
title = (title or '').replace('\x00', '')
|
|
title = title.replace(sml_token('pause'), '')
|
|
return ellipsize_utf8_bytes(title, max_bytes=max_bytes, ellipsis='…')
|
|
|
|
def strip_invalid_filename_characters(filename:str, max_bytes:int=200)->str:
|
|
# Replicates gradio upload filename sanitization for headless mode.
|
|
try:
|
|
from gradio_client.utils import strip_invalid_filename_characters as gr_strip
|
|
return gr_strip(filename, max_bytes)
|
|
except ImportError:
|
|
pass
|
|
use_blocklist = True
|
|
try:
|
|
import gradio_client
|
|
parts = gradio_client.__version__.split(".")
|
|
use_blocklist = (int(parts[0]), int(parts[1])) > (1, 13)
|
|
except Exception:
|
|
pass
|
|
name, ext = os.path.splitext(filename)
|
|
if use_blocklist:
|
|
# gradio_client > 1.13: remove only dangerous chars
|
|
name = re.sub(r'[<>:"/\\|?*\x00-\x1f\x7f`$!{}]', "", name)
|
|
else:
|
|
# gradio_client 1.13.x: keep only alnum + "._-, "
|
|
name = "".join(char for char in name if char.isalnum() or char in "._-, ")
|
|
filename = name + ext
|
|
while len(filename.encode()) > max_bytes and name:
|
|
name = name[:-1]
|
|
filename = name + ext
|
|
return filename
|
|
|
|
def delete_proc_audio_files(dir:str, files:list)->None:
|
|
base = Path(dir)
|
|
for file in base.rglob(f'[0-9]*.{default_audio_proc_format}'):
|
|
if file.stem.isdigit() and file in files:
|
|
file.unlink()
|
|
|
|
def delete_folder(folder_path:str)->None:
|
|
for name in os.listdir(folder_path):
|
|
path = os.path.join(folder_path, name)
|
|
if os.path.isfile(path) or os.path.islink(path):
|
|
os.unlink(path)
|
|
else:
|
|
shutil.rmtree(path)
|
|
|
|
def delete_unused_tmp_dirs(session_id:str, output_dir:str, days:int)->None:
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
dir_array = [
|
|
tmp_dir,
|
|
output_dir,
|
|
os.path.join(models_dir, '__sessions'),
|
|
os.path.join(voices_dir, '__sessions')
|
|
]
|
|
current_user_dirs = {
|
|
f'proc-{session_id}',
|
|
f'web-{session_id}',
|
|
f'cli-{session_id}',
|
|
f'voice-{session_id}',
|
|
f'model-{session_id}'
|
|
}
|
|
current_time = time.time()
|
|
threshold_time = current_time - (days * 24 * 60 * 60) # Convert days to seconds
|
|
for dir_path in dir_array:
|
|
if os.path.exists(dir_path) and os.path.isdir(dir_path):
|
|
for dir in os.listdir(dir_path):
|
|
if dir in current_user_dirs:
|
|
full_dir_path = os.path.join(dir_path, dir)
|
|
if os.path.isdir(full_dir_path):
|
|
try:
|
|
dir_mtime = os.path.getmtime(full_dir_path)
|
|
dir_ctime = os.path.getctime(full_dir_path)
|
|
if dir_mtime < threshold_time and dir_ctime < threshold_time:
|
|
shutil.rmtree(full_dir_path, ignore_errors=True)
|
|
msg = f'Deleted expired session: {full_dir_path}'
|
|
print(msg)
|
|
except Exception as e:
|
|
error = f'Error deleting {full_dir_path}: {e}'
|
|
print(error)
|
|
|
|
def get_compatible_tts_engines(language:str)->list[str]:
|
|
return [
|
|
engine
|
|
for engine, cfg in default_engine_settings.items()
|
|
if language in cfg.get('languages', {})
|
|
]
|
|
|
|
def translate_blocks(session_id:str, raw_blocks:list)->tuple:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
if not session or not session.get('id', False):
|
|
return raw_blocks, 'Session expired'
|
|
if not session.get('translate_enabled') or not session.get('translate'):
|
|
return raw_blocks, None
|
|
source_iso1 = session.get('language_iso1')
|
|
target_iso1 = session.get('translate_iso1')
|
|
if not source_iso1 or not target_iso1:
|
|
msg = f'Translation iso1 codes missing: {source_iso1} -> {target_iso1}'
|
|
return raw_blocks, msg
|
|
if source_iso1 == target_iso1:
|
|
return raw_blocks, None
|
|
total = len(raw_blocks)
|
|
msg = f'Translating {total} block(s) {source_iso1} -> {target_iso1}'
|
|
show_alert(session_id, {"type": "warning", "msg": msg})
|
|
translator = ArgosTranslator(neural_machine='argostranslate')
|
|
error, ok = translator.start(source_iso1, target_iso1)
|
|
if not ok:
|
|
return raw_blocks, error
|
|
if progress_bar is not None:
|
|
progress_bar(0.0, desc=msg)
|
|
out = []
|
|
tag_keys = '|'.join(map(re.escape, TTS_SML.keys()))
|
|
sml_patterns = re.compile(
|
|
rf'''
|
|
\[
|
|
\s*
|
|
(?P<close>/)?
|
|
\s*
|
|
(?P<tag>{tag_keys})
|
|
(?:\s*:\s*(?P<value>[^\]]*))?
|
|
\s*
|
|
\]
|
|
''',
|
|
re.VERBOSE
|
|
)
|
|
with tqdm(total=total, desc='translate', unit='block') as t:
|
|
for idx, text in enumerate(raw_blocks):
|
|
if session['cancellation_requested']:
|
|
return raw_blocks, 'Conversion cancelled'
|
|
if not text or not text.strip():
|
|
out.append(text)
|
|
else:
|
|
translated, ok = translator.translate(text, sml_patterns)
|
|
if not ok:
|
|
error = f'Translation failed at block {idx}: {translated}'
|
|
return raw_blocks, error
|
|
out.append(translated)
|
|
t.update(1)
|
|
if progress_bar is not None:
|
|
progress_bar((t.n) / total, desc=f'Translating block {t.n}/{total} {source_iso1} -> {target_iso1}')
|
|
msg = 'Translation done.'
|
|
print(msg)
|
|
if progress_bar is not None:
|
|
progress_bar(1.0, desc=msg)
|
|
return out, None
|
|
except Exception as e:
|
|
error = f'translate_blocks() error: {e}'
|
|
return raw_blocks, error
|
|
|
|
def convert_ebook(args:dict)->tuple:
|
|
try:
|
|
global context
|
|
error = None
|
|
session_id = None
|
|
info_session = None
|
|
if not args.get('id'):
|
|
error = 'Session ID is missing!'
|
|
return error, False
|
|
session_id = str(args['id'])
|
|
session = context.get_session(session_id)
|
|
if not session or (session and not session.get('id', False)):
|
|
error = 'Session expired or does not exist!'
|
|
return error, False
|
|
if args['language'] is not None:
|
|
try:
|
|
if len(args['language']) in (2, 3):
|
|
lang_dict = Lang(args['language'])
|
|
if lang_dict:
|
|
args['language'] = lang_dict.pt3
|
|
args['language_iso1'] = lang_dict.pt1
|
|
else:
|
|
args['language_iso1'] = None
|
|
except Exception as e:
|
|
pass
|
|
if args['language'] not in language_mapping.keys():
|
|
error = 'The language you provided is not (yet) supported'
|
|
return error, False
|
|
translate_to = args.get('translate') if args.get('translate') != args.get('language') else False
|
|
translate_enabled = bool(args.get('translate_enabled')) and bool(translate_to)
|
|
if translate_enabled:
|
|
try:
|
|
if len(translate_to) in (2, 3):
|
|
ld_t = Lang(translate_to)
|
|
if ld_t:
|
|
translate_to = ld_t.pt3
|
|
except Exception:
|
|
pass
|
|
if translate_to not in language_mapping.keys():
|
|
error = f'--translate target language {translate_to} is not (yet) supported'
|
|
return error, False
|
|
try:
|
|
target_iso1 = Lang(translate_to).pt1
|
|
except Exception:
|
|
target_iso1 = None
|
|
if not target_iso1:
|
|
error = f'--translate target {translate_to} has no iso639-1 mapping'
|
|
return error, False
|
|
args['translate_enabled'] = True
|
|
args['translate'] = translate_to
|
|
args['translate_iso1'] = target_iso1
|
|
final_language = str(args['translate'])
|
|
else:
|
|
args['translate_enabled'] = False
|
|
args['translate'] = None
|
|
args['translate_iso1'] = None
|
|
final_language = str(args['language'])
|
|
session['ebook_mode'] = args['ebook_mode']
|
|
if session['ebook_mode'] == ebook_modes['TEXT']:
|
|
if not args['ebook_textarea']:
|
|
error = 'Ebook textarea is empty.'
|
|
return error, False
|
|
text = args['ebook_textarea']
|
|
text_name = get_sanitized(text[:64])
|
|
text_name_hash = hashlib.md5(f'{text_name}_{session_id}'.encode()).hexdigest()
|
|
text_filename = SML_TAG_PATTERN.sub('', text_name)
|
|
text_filename = get_sanitized(text_filename)
|
|
text_filename = f'{text_filename[:48]}_{session_id}.txt'
|
|
text_filepath = os.path.join(tempfile.gettempdir(), text_filename)
|
|
with open(text_filepath, 'w', encoding='utf-8') as f:
|
|
f.write(text)
|
|
session['ebook_textarea'] = args['ebook_textarea']
|
|
session['ebook_textarea_src'] = text_filepath
|
|
ebook_file = strip_invalid_filename_characters(text_filename)
|
|
ebook_name = Path(text_filename).stem
|
|
else:
|
|
if not args.get('ebook_src'):
|
|
error = 'File source is empty.'
|
|
return error, False
|
|
elif not os.path.splitext(args['ebook_src'])[1]:
|
|
error = f"{args['ebook_src']} needs a format extension."
|
|
return error, False
|
|
elif not os.path.exists(args['ebook_src']):
|
|
error = 'File does not exist or Directory empty.'
|
|
return error, False
|
|
session['ebook_src'] = str(args['ebook_src'])
|
|
ebook_file = strip_invalid_filename_characters(Path(session['ebook_src']).name)
|
|
ebook_name = get_sanitized(Path(session['ebook_src']).stem)
|
|
ebook_name = strip_invalid_filename_characters(ebook_name)
|
|
print(f"Processing eBook file: {ebook_file}")
|
|
session['custom_model_dir'] = os.path.join(models_dir, '__sessions',f"model-{session_id}")
|
|
session['script_mode'] = str(args['script_mode']) if args.get('script_mode') is not None else NATIVE
|
|
session['is_gui_process'] = bool(args['is_gui_process'])
|
|
session['blocks_preview'] = bool(args['blocks_preview']) if args.get('blocks_preview') else False
|
|
session['device'] = str(args['device'])
|
|
session['language'] = str(args['language'])
|
|
session['language_iso1'] = str(args['language_iso1'])
|
|
session['translate_enabled'] = bool(args.get('translate_enabled', False))
|
|
session['translate'] = args.get('translate')
|
|
session['translate_iso1'] = args.get('translate_iso1')
|
|
session['tts_engine'] = str(args['tts_engine'])
|
|
session['custom_model'] = args['custom_model']
|
|
session['fine_tuned'] = str(args['fine_tuned'])
|
|
session['voice'] = args.get('voice', None)
|
|
session['xtts_temperature'] = float(args['xtts_temperature'])
|
|
session['xtts_length_penalty'] = float(args['xtts_length_penalty'])
|
|
session['xtts_num_beams'] = int(args['xtts_num_beams'])
|
|
session['xtts_repetition_penalty'] = float(args['xtts_repetition_penalty'])
|
|
session['xtts_top_k'] = int(args['xtts_top_k'])
|
|
session['xtts_top_p'] = float(args['xtts_top_p'])
|
|
session['xtts_speed'] = float(args['xtts_speed'])
|
|
session['xtts_enable_text_splitting'] = bool(args['xtts_enable_text_splitting'])
|
|
session['bark_text_temp'] = float(args['bark_text_temp'])
|
|
session['bark_waveform_temp'] = float(args['bark_waveform_temp'])
|
|
session['output_format'] = str(args['output_format'])
|
|
session['output_channel'] = str(args['output_channel'])
|
|
session['output_split'] = bool(args['output_split'])
|
|
session['output_split_hours'] = args['output_split_hours']if args['output_split_hours'] is not None else default_output_split_hours
|
|
session['model_cache'] = f"{session['tts_engine']}-{session['fine_tuned']}"
|
|
session['session_dir'] = os.path.join(tmp_dir, f'proc-{session_id}')
|
|
session['status'] = status_tags['EDIT'] if session['blocks_preview'] else status_tags['CONVERTING']
|
|
lang_prfx = (f'_{final_language}' if session.get('translate_enabled') else '')
|
|
session['process_dir'] = os.path.join(session['session_dir'], hashlib.md5((ebook_name + lang_prfx).encode()).hexdigest())
|
|
session['chapters_dir'] = os.path.join(session['process_dir'], 'chapters')
|
|
session['sentences_dir'] = os.path.join(session['chapters_dir'], 'sentences')
|
|
cleanup_models_cache()
|
|
if session['is_gui_process']:
|
|
session['final_name'] = ebook_name + lang_prfx + '.' + session['output_format']
|
|
else:
|
|
session['system'] = DEVICE_SYSTEM
|
|
session['audiobooks_dir'] = os.path.abspath(args['output_dir']) if args.get('output_dir') is not None else os.path.join(audiobooks_cli_dir, f'cli-{session_id}')
|
|
session['final_name'] = os.path.join(session['audiobooks_dir'], ebook_name + lang_prfx + '.' + session['output_format'])
|
|
session['voice_dir'] = os.path.join(voices_dir, '__sessions', f'voice-{session_id}', final_language)
|
|
os.makedirs(session['voice_dir'], exist_ok=True)
|
|
audio_pre_final_exist = os.path.exists(os.path.join(session['process_dir'], ebook_name + '.' + default_audio_proc_format))
|
|
audio_sentences_exist = any(Path(session['sentences_dir']).rglob(f'*.{default_audio_proc_format}'))
|
|
if audio_pre_final_exist or audio_sentences_exist:
|
|
msg = f"Warning! audio sentences or final file {ebook_name} of this conversion already exists!"
|
|
print(msg)
|
|
while True:
|
|
choice = input("[s]kip / [r]esume / [d]elete and convert again: ").strip().lower()
|
|
if choice in ('s', 'r', 'd'):
|
|
break
|
|
print("Please enter 's', 'r' or 'd'.")
|
|
if choice == 'r':
|
|
if audio_pre_final_exist:
|
|
os.unlink(audio_pre_final_exist)
|
|
if os.path.exists(session['final_name']):
|
|
os.unlink(session['final_name'])
|
|
elif choice == 'd':
|
|
delete_folder(session['process_dir'])
|
|
elif choice == 's':
|
|
msg = 'Conversion skipped.'
|
|
return msg, True
|
|
if error is None:
|
|
delete_unused_tmp_dirs(session_id, audiobooks_cli_dir, tmp_expire)
|
|
if session['custom_model'] is not None:
|
|
if not os.path.exists(session['custom_model_dir']):
|
|
os.makedirs(session['custom_model_dir'], exist_ok=True)
|
|
custom_src_path = Path(session['custom_model'])
|
|
custom_src_name = custom_src_path.stem
|
|
if not os.path.exists(os.path.join(session['custom_model_dir'], custom_src_name)):
|
|
try:
|
|
if analyze_uploaded_file(session['custom_model'], default_engine_settings[session['tts_engine']]['files']):
|
|
model = extract_custom_model(session_id)
|
|
if model is not None:
|
|
session['custom_model'] = model
|
|
else:
|
|
error = f"{model} could not be extracted or mandatory files are missing"
|
|
else:
|
|
error = f'{os.path.basename(f)} is not a valid model or some required files are missing'
|
|
except ModuleNotFoundError as e:
|
|
error = f"No presets module for TTS engine '{session['tts_engine']}': {e}"
|
|
if session.get('voice'):
|
|
voice_name = os.path.splitext(os.path.basename(session['voice']))[0].replace('&', 'And')
|
|
voice_name = get_sanitized(voice_name)
|
|
final_voice_file = os.path.join(session['voice_dir'], f'{voice_name}.wav')
|
|
if not os.path.exists(final_voice_file):
|
|
extractor = VoiceExtractor(session, session['voice'], voice_name)
|
|
voice_status, msg = extractor.extract_voice()
|
|
if voice_status:
|
|
session['voice'] = final_voice_file
|
|
else:
|
|
error = f'VoiceExtractor.extract_voice() failed! {msg}'
|
|
if error is None:
|
|
if prepare_dirs(session_id):
|
|
session['ebook'] = os.path.join(session['process_dir'], ebook_file)
|
|
shutil.copy((session['ebook_textarea_src'] if session['ebook_mode'] == ebook_modes['TEXT'] else session['ebook_src']), session['ebook'])
|
|
session['filename_noext'] = os.path.splitext(os.path.basename(session['ebook']))[0]
|
|
msg = ''
|
|
msg_extra = ''
|
|
if session['device'] == devices['CUDA']['proc']:
|
|
if not devices['CUDA']['found']:
|
|
session['device'] = devices['CPU']['proc']
|
|
msg += f'CUDA not supported by the Torch installed!<br/>Read {default_gpu_wiki}<br/>Switching to CPU'
|
|
elif session['device'] == devices['JETSON']['proc'] or session['device'] == devices['JETSON']['proc']:
|
|
if not devices['JETSON']['found']:
|
|
session['device'] = devices['CPU']['proc']
|
|
msg += f'JETSON CUDA not supported by the Torch installed!<br/>Read {default_gpu_wiki}<br/>Switching to CPU'
|
|
elif session['device'] == devices['MPS']['proc']:
|
|
if not devices['MPS']['found']:
|
|
session['device'] = devices['CPU']['proc']
|
|
msg += f'MPS not supported by the Torch installed!<br/>Read {default_gpu_wiki}<br/>Switching to CPU'
|
|
elif session['device'] == devices['ROCM']['proc']:
|
|
if not devices['ROCM']['found']:
|
|
session['device'] = devices['CPU']['proc']
|
|
msg += f'ROCM not supported by the Torch installed!<br/>Read {default_gpu_wiki}<br/>Switching to CPU'
|
|
elif session['device'] == devices['XPU']['proc']:
|
|
if not devices['XPU']['found']:
|
|
session['device'] = devices['CPU']['proc']
|
|
msg += f"XPU not supported by the Torch installed!<br/>Read {default_gpu_wiki}<br/>Switching to CPU"
|
|
vram_dict = VRAMDetector().detect_vram(session['device'], session['script_mode'])
|
|
print(f'vram_dict: {vram_dict}')
|
|
total_vram_gb = vram_dict.get('total_vram_gb', 0)
|
|
detected_free_vram_gb = vram_dict.get('free_vram_gb', 0)
|
|
session['free_vram_gb'] = detected_free_vram_gb
|
|
if session['free_vram_gb'] == 0:
|
|
msg_extra += f"<br/>Memory capacity not detected! restrict to {session['free_vram_gb']}GB max"
|
|
else:
|
|
msg_extra += f"<br/>Free Memory available: {session['free_vram_gb']}GB"
|
|
if session['free_vram_gb'] < default_engine_settings[session['tts_engine']]['rating']['VRAM']:
|
|
msg_extra += f"<br/>Free Memory {session['free_vram_gb']} is lower than VRAM/RAM {default_engine_settings[session['tts_engine']]['rating']['VRAM']}GB required!<br/>It will probably crash the conversion!"
|
|
if session['free_vram_gb'] > 4.0:
|
|
if session['tts_engine'] == TTS_ENGINES['BARK']:
|
|
os.environ['SUNO_USE_SMALL_MODELS'] = 'FALSE'
|
|
if session['tts_engine'] == TTS_ENGINES['BARK']:
|
|
if session['free_vram_gb'] < 12.0:
|
|
os.environ['SUNO_OFFLOAD_CPU'] = "TRUE"
|
|
os.environ['SUNO_USE_SMALL_MODELS'] = "TRUE"
|
|
msg_extra += f"<br/>Switching BARK to SMALL models"
|
|
else:
|
|
os.environ['SUNO_OFFLOAD_CPU'] = "FALSE"
|
|
os.environ['SUNO_USE_SMALL_MODELS'] = "FALSE"
|
|
if msg == '':
|
|
msg_extra = f"Using {session['device'].upper()}" + msg_extra
|
|
device_vram_required = default_engine_settings[session['tts_engine']]['rating']['RAM'] if session['device'] == devices['CPU']['proc'] else default_engine_settings[session['tts_engine']]['rating']['VRAM']
|
|
if float(total_vram_gb) >= float(device_vram_required):
|
|
if msg:
|
|
show_alert(session_id, {"type": "warning", "msg": msg + msg_extra})
|
|
else:
|
|
show_alert(session_id, {"type": "info", "msg": msg_extra})
|
|
session['epub_path'] = os.path.join(session['process_dir'], f"__{session['filename_noext']}.epub")
|
|
session['blocks_orig_json'] = os.path.join(session['process_dir'], f"{file_prefixes['clone']}{session['filename_noext']}.json")
|
|
session['blocks_saved_json'] = os.path.join(session['process_dir'], f"{file_prefixes['saved']}{session['filename_noext']}.json")
|
|
session['blocks_current_db'] = os.path.join(session['process_dir'], f"{file_prefixes['current']}{session['filename_noext']}.db")
|
|
ok_checksum, error = compare_checksums(session_id)
|
|
if not ok_checksum or not os.path.exists(session['epub_path']):
|
|
result_epub = convert2epub(session_id)
|
|
if result_epub:
|
|
if os.path.exists(session['epub_path']):
|
|
for jf in (session['blocks_orig_json'], session['blocks_saved_json']):
|
|
if os.path.exists(jf):
|
|
os.unlink(jf)
|
|
db = session['blocks_current_db']
|
|
for f in (db, db + '-wal', db + '-shm'):
|
|
if os.path.exists(f):
|
|
os.unlink(f)
|
|
msg = f"NOTE: process folder {session['process_dir']} is strictly used for internal tasks and has nothing to do with the final conversion."
|
|
print(msg)
|
|
else:
|
|
error = f"convert2epub() {session['epub_path']} does not exists! check write permissions."
|
|
else:
|
|
error = 'convert2epub() error: could not convert to epub file!'
|
|
if error is None:
|
|
missing_orig_json = True
|
|
if os.path.exists(session['blocks_orig_json']):
|
|
missing_orig_json = False
|
|
blocks_orig = load_json_blocks(session['blocks_orig_json'])
|
|
is_changed = False
|
|
if blocks_orig:
|
|
blocks = blocks_orig.get('blocks', [])
|
|
new_blocks = []
|
|
for block in blocks:
|
|
if any(c.isalnum() for c in block.get('text','')):
|
|
if not block.get('id'):
|
|
block['id'] = str(uuid.uuid4())
|
|
is_changed = True
|
|
new_blocks.append(block)
|
|
blocks_orig['blocks'] = new_blocks
|
|
session['blocks_orig'] = blocks_orig
|
|
if is_changed:
|
|
save_json_blocks(session_id, 'blocks_orig')
|
|
if os.path.exists(session['blocks_saved_json']):
|
|
blocks_saved = load_json_blocks(session['blocks_saved_json'])
|
|
if blocks_saved:
|
|
session['blocks_saved'] = blocks_saved
|
|
if is_changed:
|
|
if is_changed:
|
|
blocks = blocks_saved.get('blocks', [])
|
|
for i, block in enumerate(blocks):
|
|
if i < len(blocks_orig['blocks']):
|
|
block['id'] = blocks_orig['blocks'][i]['id']
|
|
blocks_saved['blocks'] = blocks
|
|
session['blocks_saved'] = blocks_saved
|
|
save_json_blocks(session_id, 'blocks_saved')
|
|
if os.path.exists(session['blocks_current_db']):
|
|
blocks_current = load_db_blocks(session['blocks_current_db'])
|
|
if blocks_current:
|
|
session['blocks_current'] = blocks_current
|
|
if is_changed:
|
|
if is_changed:
|
|
blocks = blocks_current.get('blocks', [])
|
|
for i, block in enumerate(blocks):
|
|
if i < len(blocks_orig['blocks']):
|
|
block['id'] = blocks_orig['blocks'][i]['id']
|
|
blocks_current['blocks'] = blocks
|
|
session['blocks_current'] = blocks_current
|
|
save_db_blocks(session_id)
|
|
epubBook = epub.read_epub(session['epub_path'], {'ignore_ncx': True})
|
|
if epubBook:
|
|
metadata = dict(session['metadata'])
|
|
for key, value in metadata.items():
|
|
data = epubBook.get_metadata('DC', key)
|
|
if data:
|
|
for value, attributes in data:
|
|
metadata[key] = value
|
|
metadata['language'] = final_language
|
|
metadata['title'] = metadata['title'] or Path(session['ebook']).stem.replace('_', ' ')
|
|
metadata['creator'] = False if not metadata['creator'] or metadata['creator'] == 'Unknown' else metadata['creator']
|
|
session['metadata'] = metadata
|
|
try:
|
|
if len(session['metadata']['language']) == 2:
|
|
lang_dict = Lang(final_language)
|
|
if lang_dict:
|
|
session['metadata']['language'] = lang_dict.pt3
|
|
except Exception as e:
|
|
pass
|
|
if not session.get('translate_enabled'):
|
|
if session['metadata']['language'] != session['language']:
|
|
error = f"WARNING!!! language selected {session['language']} differs from the EPUB file language {session['metadata']['language']}"
|
|
show_alert(session_id, {'type': 'warning', 'msg': error})
|
|
is_lang_in_tts_engine = (
|
|
session.get('tts_engine') in default_engine_settings and
|
|
final_language in default_engine_settings[session['tts_engine']].get('languages', {})
|
|
)
|
|
if is_lang_in_tts_engine:
|
|
session['cover'] = get_cover(epubBook, session_id)
|
|
if session.get('cover', False):
|
|
if missing_orig_json:
|
|
raw_blocks = get_blocks(session_id, epubBook)
|
|
if raw_blocks and session.get('translate_enabled'):
|
|
raw_blocks, error = translate_blocks(session_id, list(raw_blocks))
|
|
if error is not None:
|
|
return error, False
|
|
if raw_blocks:
|
|
session['blocks_orig'] = {
|
|
"page": 0,
|
|
"block_resume": 0,
|
|
"sentence_resume": 0,
|
|
"voice": session['voice'],
|
|
"tts_engine": session['tts_engine'],
|
|
"fine_tuned": session['fine_tuned'],
|
|
"blocks": [
|
|
{
|
|
"id": str(uuid.uuid4()),
|
|
"expand": False,
|
|
"keep": True,
|
|
"text": t,
|
|
"voice": session['voice'],
|
|
"tts_engine": session['tts_engine'],
|
|
"fine_tuned": session['fine_tuned'],
|
|
"sentences": [],
|
|
}
|
|
for t in raw_blocks if t
|
|
],
|
|
}
|
|
if session.get('blocks_orig', {}):
|
|
save_json_blocks(session_id, 'blocks_orig')
|
|
if not session.get('blocks_current', {}):
|
|
session['blocks_current'] = copy.deepcopy(session['blocks_orig'])
|
|
save_db_blocks(session_id)
|
|
if session.get('blocks_orig', {}) and session.get('blocks_current', {}):
|
|
sync_globals_to_blocks(session_id)
|
|
if session['blocks_preview']:
|
|
msg = f'Chapters preview requested. Select which block to convert:'
|
|
print(msg)
|
|
progress_status = os.path.basename(session['ebook'])
|
|
return progress_status, True
|
|
else:
|
|
progress_status, passed = finalize_audiobook(session_id)
|
|
return progress_status, passed
|
|
else:
|
|
error = f"get_blocks() or save_json_blocks() failed! {session['blocks_orig']}"
|
|
else:
|
|
error = 'get_cover() failed!'
|
|
else:
|
|
error = f"language {final_language} not supported by {session['tts_engine']}!"
|
|
else:
|
|
error = 'epubBook.read_epub failed!'
|
|
else:
|
|
error = f"Your device has not enough memory ({total_vram_gb}GB) to run {session['tts_engine']} engine ({device_vram_required}GB)"
|
|
else:
|
|
error = f"Temporary directory {session['process_dir']} not removed due to failure."
|
|
if session['cancellation_requested']:
|
|
error = 'Conversion Cancelled'
|
|
return error, False
|
|
except Exception as e:
|
|
error = f'convert_ebook() Exception: {e}\n{traceback.format_exc()}'
|
|
return error, False
|
|
|
|
def finalize_audiobook(session_id:str)->tuple:
|
|
try:
|
|
session = context.get_session(session_id)
|
|
is_preview = session.get('blocks_preview', False) if session else False
|
|
result = lambda msg, ok: (gr.update(value=msg), gr.update(value=ok)) if is_preview else (msg, ok)
|
|
|
|
def _fail(error):
|
|
session['status'] = status_tags['END']
|
|
return result(error, False)
|
|
|
|
if not session or not session.get('id', False):
|
|
msg = 'session expired!'
|
|
return result(msg, False)
|
|
if session['status'] not in [status_tags['EDIT'], status_tags['CONVERTING']]:
|
|
msg = 'No blocks have been selected for the conversion!'
|
|
return result(msg, False)
|
|
if not session.get('blocks_current', {}):
|
|
error = 'finalize_audiobook() failed! blocks_current empty!'
|
|
return _fail(error)
|
|
session['status'] = status_tags['CONVERTING']
|
|
print('Get sentences…')
|
|
blocks_current = session['blocks_current']
|
|
blocks = blocks_current['blocks']
|
|
for idx, block in enumerate(blocks):
|
|
if session['cancellation_requested']:
|
|
if session['status'] == status_tags['DISCONNECTED']:
|
|
context_tracker.end_session(session_id, session['socket_hash'])
|
|
msg = 'Frontend disconnected!'
|
|
return result(msg, False)
|
|
msg = 'Conversion cancelled'
|
|
return result(msg, False)
|
|
if not block['keep'] or not block['text'].strip():
|
|
block['sentences'] = []
|
|
continue
|
|
if block.get('sentences', []):
|
|
print(f'Block {idx} — sentences already split, skipping')
|
|
continue
|
|
sentences_list = get_sentences(session_id, block['text'])
|
|
if sentences_list is None:
|
|
error = 'No sentences found!'
|
|
return result(error, False)
|
|
block['sentences'] = sentences_list
|
|
blocks_current['blocks'] = blocks
|
|
session['blocks_current'] = blocks_current
|
|
conversion = convert_chapters2audio(session_id)
|
|
if not conversion:
|
|
error = 'convert_chapters2audio() failed!'
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
if session['cancellation_requested']:
|
|
error = 'Conversion cancelled'
|
|
return _fail(error)
|
|
show_alert(session_id, {'type': 'info', 'msg': 'Combining sentences and chapters…'})
|
|
exported_files = combine_audio_chapters(session_id)
|
|
if exported_files is None:
|
|
return _fail('combine_audio_chapters() error: exported_files not created!')
|
|
session['audiobook'] = exported_files[-1]
|
|
filename = os.path.basename(session['ebook'])
|
|
count_ebook = 0
|
|
if session['ebook_mode'] == ebook_modes['DIRECTORY']:
|
|
if isinstance(session['ebook_list'], list):
|
|
if session['ebook_src'] in session['ebook_list']:
|
|
ebook_list = session['ebook_list']
|
|
ebook_list.remove(session['ebook_src'])
|
|
session['ebook_list'] = ebook_list
|
|
count_ebook = len(session['ebook_list'])
|
|
if count_ebook > 0:
|
|
reset_ebook_session(session_id, force=True, filter_keys=False)
|
|
show_alert(session_id, {'type': 'success', 'msg': f'{filename} / converted. {count_ebook} ebook(s) conversion remaining…'})
|
|
else:
|
|
if session['ebook_mode'] == ebook_modes['DIRECTORY']:
|
|
session['ebook_list'] = None
|
|
session['voice_map'] = {}
|
|
session['ebook_selected'] = None
|
|
elif session['ebook_mode'] == ebook_modes['SINGLE']:
|
|
session['ebook_src'] = None
|
|
elif session['ebook_mode'] == ebook_modes['TEXT']:
|
|
try:
|
|
os.remove(session['ebook_textarea_src'])
|
|
except FileNotFoundError:
|
|
pass
|
|
except OSError:
|
|
pass
|
|
session['status'] = status_tags['END']
|
|
reset_ebook_session(session_id, force=True, filter_keys=False)
|
|
show_alert(session_id, {'type': 'success', 'msg': f'{filename} / converted.'})
|
|
print(f'*********** Session: {session_id} **************\n{session_info}')
|
|
return result(filename, True)
|
|
except Exception as e:
|
|
session['status'] = status_tags['END']
|
|
reset_ebook_session(session_id, force=True, filter_keys=False)
|
|
DependencyError(e)
|
|
error = f'finalize_audiobook(): {e}'
|
|
exception_alert(session_id, error)
|
|
return result(error, False)
|
|
|
|
def on_unload(req:gr.Request)->None:
|
|
socket_hash = req.session_hash
|
|
if any(socket_hash in session for session in context.sessions.values()):
|
|
session_id = context.find_id_by_hash(socket_hash)
|
|
if session_id:
|
|
session = context.get_session(session_id)
|
|
if session['status'] == status_tags['CONVERTING']:
|
|
session['cancellation_requested'] = True
|
|
session['status'] = status_tags['DISCONNECTED']
|
|
session['socket_hash'] = socket_hash
|
|
else:
|
|
context_tracker.end_session(session_id, socket_hash)
|
|
|
|
def restore_session_from_data(data:dict, session:DictProxy, force:bool, filter_keys:bool)->None:
|
|
try:
|
|
for key, value in data.items():
|
|
if key in session:
|
|
if filter_keys and key in save_session_keys_except:
|
|
continue
|
|
if isinstance(value, dict) and value and isinstance(session[key], dict):
|
|
nested = session[key]
|
|
restore_session_from_data(value, nested, force, filter_keys)
|
|
session[key] = nested
|
|
else:
|
|
if not force:
|
|
if value is None and session[key] is not None:
|
|
continue
|
|
session[key] = value
|
|
except Exception as e:
|
|
DependencyError(e)
|
|
error = f'restore_session_from_data(): {e}'
|
|
exception_alert(session['id'], error)
|
|
|
|
def reset_ebook_session(session_id:str, force:bool, filter_keys:bool)->None:
|
|
session = context.get_session(session_id)
|
|
data = {
|
|
"ebook": None,
|
|
"ebook_textarea_src": None,
|
|
"process_dir": None,
|
|
"chapters_dir": None,
|
|
"sentences_dir": None,
|
|
"epub_path": None,
|
|
"final_name": None,
|
|
"filename_noext": None,
|
|
"cover": None,
|
|
"blocks_orig": {},
|
|
"blocks_saved": {},
|
|
"blocks_current": {},
|
|
"blocks_orig_json": None,
|
|
"blocks_saved_json": None,
|
|
"blocks_current_db": None,
|
|
"audiobook_overridden": None,
|
|
"metadata": {
|
|
"title": None,
|
|
"creator": None,
|
|
"contributor": None,
|
|
"language": None,
|
|
"identifier": None,
|
|
"publisher": None,
|
|
"date": None,
|
|
"description": None,
|
|
"subject": None,
|
|
"rights": None,
|
|
"format": None,
|
|
"type": None,
|
|
"coverage": None,
|
|
"relation": None,
|
|
"Source": None,
|
|
"Modified": None,
|
|
}
|
|
}
|
|
restore_session_from_data(data, session, force, filter_keys=filter_keys)
|
|
|
|
def cleanup_models_cache()->None:
|
|
try:
|
|
active_models = {
|
|
cache
|
|
for session in context.sessions.values()
|
|
for cache in (session.get('model_cache'), session.get('model_zs_cache'), session.get('stanza_cache'))
|
|
if cache is not None
|
|
}
|
|
for key in list(loaded_tts.keys()):
|
|
if key not in active_models:
|
|
del loaded_tts[key]
|
|
gc.collect()
|
|
except Exception as e:
|
|
error = f"cleanup_models_cache() error: {e}"
|
|
print(error)
|
|
|
|
def show_alert(session_id:str|None, state:dict|None)->None:
|
|
if state is not None:
|
|
if state.get('msg'):
|
|
print(state['msg'].replace('<br/>', '\n'))
|
|
if session_id is not None:
|
|
session = context.get_session(session_id)
|
|
if session.get('is_gui_process'):
|
|
if isinstance(state, dict):
|
|
if state['type'] is not None:
|
|
if state['type'] == 'error':
|
|
raise gr.Error(state['msg'])
|
|
elif state['type'] == 'warning':
|
|
gr.Warning(state['msg'])
|
|
elif state['type'] == 'info':
|
|
gr.Info(state['msg'])
|
|
elif state['type'] == 'success':
|
|
gr.Success(state['msg'])
|
|
|
|
def exception_alert(session_id:str|None, error:str|None)->None:
|
|
if error is not None:
|
|
print(error.replace('<br/>', '\n'))
|
|
if session_id is not None:
|
|
session = context.get_session(session_id)
|
|
if session and session.get('id', False):
|
|
session['status'] = status_tags['READY']
|
|
if session['is_gui_process']:
|
|
raise gr.Error(error)
|
|
|
|
def get_all_ip_addresses()->list:
|
|
ip_addresses = []
|
|
for interface, addresses in psutil.net_if_addrs().items():
|
|
for address in addresses:
|
|
if address.family in [socket.AF_INET, socket.AF_INET6]:
|
|
ip_addresses.append(address.address)
|
|
return ip_addresses
|