import re
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from audify.utils.audio import AudioProcessor
from audify.utils.file_utils import PathManager
[docs]
def contains_cjk(text: str) -> bool:
"""Check if text contains CJK (Chinese, Japanese, Korean) characters."""
# Basic CJK Unicode ranges
for char in text:
if any(
start <= ord(char) <= end
for start, end in [
(0x4E00, 0x9FFF), # CJK Unified Ideographs
(0x3400, 0x4DBF), # CJK Extension A
(0x20000, 0x2A6DF), # CJK Extension B
(0x2A700, 0x2B73F), # CJK Extension C
(0x2B740, 0x2B81F), # CJK Extension D
(0x2B820, 0x2CEAF), # CJK Extension E
(0x2CEB0, 0x2EBEF), # CJK Extension F
(0x3000, 0x303F), # CJK Symbols and Punctuation
(0xFF00, 0xFFEF), # Halfwidth and Fullwidth Forms
]
):
return True
return False
[docs]
def clean_text(text: str) -> str:
"""Clean text for TTS: normalize whitespace, fix punctuation spacing.
Preserves content within brackets, parentheses, and quotes — only
normalizes the whitespace around them. Removes bare << and >> markers
that sometimes survive EPUB parsing.
"""
# Collapse all whitespace runs to single spaces
cleaned = re.sub(r"\s+", " ", text).strip()
# Replace @ with 'a' to avoid TTS errors
cleaned = cleaned.replace("@", "a")
# Remove spaces before punctuation
cleaned = re.sub(r" ([.,!?;:¿¡-])", r"\1", cleaned)
# Remove orphaned angle-bracket remnants from HTML parsing
cleaned = cleaned.replace("<<", "\"").replace(">>", "\"")
cleaned = re.sub(r"<[^>]*>", "", cleaned)
# Remove double spaces again
cleaned = re.sub(r" +", " ", cleaned)
# Remove multiple consecutive punctuation marks
cleaned = re.sub(r"([.,!?;:¿¡]){2,}", r"\1", cleaned)
# Remove leading/trailing spaces
cleaned = cleaned.strip()
return cleaned
[docs]
def combine_small_sentences(sentences: list[str], min_length: int = 10) -> list[str]:
result: list[str] = []
for sentence in sentences:
if len(sentence) < min_length:
if result:
result[-1] += " " + sentence
else:
result.append(sentence)
return result
[docs]
def break_too_long_sentences(sentences: list[str], max_length: int = 239) -> list[str]:
result: list[str] = []
for sentence in sentences:
# If sentence contains CJK characters and has few spaces,
# split by character length.
if (
contains_cjk(sentence) and sentence.count(" ") < len(sentence) / 50
): # heuristic
# Split by character count
start = 0
while start < len(sentence):
# Take up to max_length characters, but try punctuation boundaries.
end = start + max_length
if end >= len(sentence):
result.append(sentence[start:].strip())
break
# Look for punctuation break in the last 20% of segment
lookback = int(max_length * 0.2)
punctuation_marks = "。!?;:.!?;:¿¡"
break_pos = -1
for i in range(end - 1, end - lookback - 1, -1):
if i >= start and sentence[i] in punctuation_marks:
break_pos = i + 1 # include punctuation
break
if break_pos > start:
result.append(sentence[start:break_pos].strip())
start = break_pos
else:
# No punctuation found, split at max_length
result.append(sentence[start:end].strip())
start = end
else:
# Original word-based splitting for non-CJK or spaced text
sentence_words = sentence.split()
new_sentence = ""
for word in sentence_words:
if len(new_sentence) + len(word) > max_length:
result.append(new_sentence.strip(" "))
new_sentence = ""
new_sentence += word + " "
if new_sentence:
result.append(new_sentence.strip(" "))
return result
[docs]
def break_text_into_sentences(
text: str, max_length: int = 5000, min_length: int = 20
) -> list[str]:
# Split text into sentences using punctuation marks (Western and Chinese)
# Includes: .!?;:¿¡。!?;:
# Split on punctuation followed by optional whitespace
sentences = re.split(r"(?<=[.!?;:¿¡。!?;:])\s*", text)
# Filter out empty strings
sentences = [s for s in sentences if s.strip()]
# Split long sentences into smaller ones to avoid TTS errors
result = break_too_long_sentences(sentences, max_length - min_length)
# Combine sentences that are too short with the previous one
result = combine_small_sentences(result, min_length)
# Parallelize the cleaning of the sentences
with ThreadPoolExecutor() as executor:
result = list(executor.map(clean_text, result))
return result
[docs]
def get_audio_duration(file_path: str) -> float:
"""Get audio duration in seconds. Delegates to AudioProcessor.get_duration."""
return AudioProcessor.get_duration(file_path)
[docs]
def get_file_extension(file_path: str) -> str:
return Path(file_path).suffix
[docs]
def get_file_name_title(title: str) -> str:
"""Convert a title to a filesystem-safe snake_case name.
Delegates to ``PathManager.clean_file_name`` so the logic lives in one place.
"""
return PathManager.clean_file_name(title)