Files
abogen/abogen/conversion.py
T

1255 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import re
import tempfile
import time
import chardet
import charset_normalizer
import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir
from PyQt5.QtCore import QThread, pyqtSignal, Qt
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import soundfile as sf
from utils import clean_text, create_process
from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
from voice_formulas import get_new_voice
import hf_tracker
import static_ffmpeg
import threading # for efficient waiting
import subprocess
def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
def detect_encoding(file_path):
with open(file_path, "rb") as f:
raw_data = f.read()
detected_encoding = None
for detectors in (charset_normalizer, chardet):
try:
result = detectors.detect(raw_data)["encoding"]
except Exception:
continue
if result is not None:
detected_encoding = result
break
encoding = detected_encoding if detected_encoding else "utf-8"
return encoding.lower()
class ChapterOptionsDialog(QDialog):
def __init__(self, chapter_count, parent=None):
super().__init__(parent)
self.setWindowTitle("Chapter Options")
self.setMinimumWidth(350)
# Prevent closing with the X button and remove the help button
self.setWindowFlags(
self.windowFlags()
& ~Qt.WindowCloseButtonHint
& ~Qt.WindowContextHelpButtonHint
)
layout = QVBoxLayout(self)
# Add informational label
layout.addWidget(QLabel(f"Detected {chapter_count} chapters in the text file."))
layout.addWidget(QLabel("How would you like to process these chapters?"))
# Add checkboxes
self.save_separately_checkbox = QCheckBox("Save each chapter separately")
self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end")
# Set default states
self.save_separately_checkbox.setChecked(True)
self.merge_at_end_checkbox.setChecked(True)
# Connect checkbox state change signal
self.save_separately_checkbox.stateChanged.connect(
self.update_merge_checkbox_state
)
layout.addWidget(self.save_separately_checkbox)
layout.addWidget(self.merge_at_end_checkbox)
# Add OK button
button_box = QDialogButtonBox(QDialogButtonBox.Ok)
button_box.accepted.connect(self.accept)
layout.addWidget(button_box)
# Initialize merge checkbox state
self.update_merge_checkbox_state()
def update_merge_checkbox_state(self):
# Enable merge checkbox only if save separately is checked
self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked())
# Don't uncheck it, just leave it in its current state
def get_options(self):
save_separately = self.save_separately_checkbox.isChecked()
# Consider merge_at_end as false if the checkbox is disabled, regardless of its checked state
merge_at_end = (
self.merge_at_end_checkbox.isChecked()
and self.merge_at_end_checkbox.isEnabled()
)
return {
"save_chapters_separately": save_separately,
"merge_chapters_at_end": merge_at_end,
}
# Prevent closing by overriding the closeEvent
def closeEvent(self, event):
# Ignore all close events
event.ignore()
# Prevent escape key from closing the dialog
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape:
event.ignore()
else:
super().keyPressEvent(event)
class ConversionThread(QThread):
progress_updated = pyqtSignal(int, str) # Add str for ETR
conversion_finished = pyqtSignal(object, object) # Pass output path as second arg
log_updated = pyqtSignal(object) # Updated signal for log updates
chapters_detected = pyqtSignal(int) # Signal for chapter detection
def __init__(
self,
file_name,
lang_code,
speed,
voice,
save_option,
output_folder,
subtitle_mode,
output_format,
np_module,
kpipeline_class,
start_time,
total_char_count,
use_gpu=True,
): # Add use_gpu parameter
super().__init__()
self._chapter_options_event = threading.Event()
self.np = np_module
self.KPipeline = kpipeline_class
self.file_name = file_name
self.lang_code = lang_code
self.speed = speed
self.voice = voice
self.save_option = save_option
self.output_folder = output_folder
self.subtitle_mode = subtitle_mode
self.cancel_requested = False
self.output_format = output_format
self.start_time = start_time # Store start_time
self.total_char_count = total_char_count # Use passed total character count
self.processed_char_count = 0 # Initialize processed character count
self.display_path = None # Add variable for display path
self.is_direct_text = (
False # Flag to indicate if input is from textbox rather than file
)
self.chapter_options_set = False
self.waiting_for_user_input = False
self.use_gpu = use_gpu # Store the GPU setting
self.max_subtitle_words = 50 # Default value, will be overridden from GUI
def _stream_audio_in_chunks(self, segments, process_func, progress_prefix="Processing"):
"""
Process audio segments in memory-efficient chunks
Args:
segments: List of audio segments to process
process_func: Function that takes (segment_bytes, is_last) and processes a chunk
progress_prefix: Prefix for progress messages
Returns:
Total samples processed
"""
# Calculate total size for progress reporting
total_samples = sum(len(segment) for segment in segments)
samples_processed = 0
self.log_updated.emit(f"\n{progress_prefix} segments...")
# Stream each segment individually
for i, segment in enumerate(segments):
try:
# Handle both NumPy arrays and PyTorch tensors
if hasattr(segment, 'astype'):
segment_bytes = segment.astype("float32").tobytes()
else:
segment_bytes = segment.cpu().numpy().astype("float32").tobytes()
is_last = (i == len(segments) - 1)
# Update progress periodically - skip if there's only one segment
if (i % 20 == 0 or is_last) and len(segments) > 1:
progress_percent = int((samples_processed / total_samples) * 100)
self.log_updated.emit(f"{progress_prefix} segment {i+1}/{len(segments)} ({progress_percent}% complete)")
# Process this segment
process_func(segment_bytes, is_last)
# Update samples processed
samples_processed += len(segment)
# Clear segment bytes from memory
del segment_bytes
except Exception as e:
self.log_updated.emit(f"Error processing segment {i}: {str(e)}")
raise
return samples_processed
def _process_audio_segments(self, audio_segments, output_path, output_format, use_ffmpeg=False, ffmpeg_args=None):
"""
Process audio segments to a target format with memory-efficient handling
Args:
audio_segments: List of audio segments to process
output_path: Path for output file
output_format: Format of output (wav, mp3, flac, opus, m4b)
use_ffmpeg: Whether to use FFmpeg instead of soundfile
ffmpeg_args: Optional additional FFmpeg arguments when use_ffmpeg=True
Returns:
Tuple of (success, output_path)
"""
self.log_updated.emit(f"\nProcessing audio data to {output_format.upper()}...")
segments_count = len(audio_segments)
# Handle direct streaming for WAV (the only format supporting append mode)
if output_format == "wav" and not use_ffmpeg:
try:
# Write first segment
sf.write(output_path, audio_segments[0], 24000, format="wav")
# Append remaining segments
for i, segment in enumerate(audio_segments[1:], 1):
with sf.SoundFile(output_path, mode='r+') as f:
f.seek(0, sf.SEEK_END)
f.write(segment)
return True, output_path
except Exception as e:
self.log_updated.emit((f"Error writing WAV file: {str(e)}", "red"))
return False, None
# For formats requiring FFmpeg (opus, m4b) or when explicitly requested
if use_ffmpeg or output_format in ["opus", "m4b"]:
static_ffmpeg.add_paths()
# Basic FFmpeg command
cmd = [
"ffmpeg", "-y",
"-thread_queue_size", "32768",
"-f", "f32le",
"-ar", "24000",
"-ac", "1",
"-i", "pipe:0"
]
# Add custom FFmpeg arguments if provided
if ffmpeg_args:
cmd.extend(ffmpeg_args)
else:
# Default codec settings based on format
if output_format == "opus":
cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
elif output_format == "mp3":
cmd.extend(["-c:a", "libmp3lame", "-q:a", "2"])
elif output_format == "flac":
cmd.extend(["-c:a", "flac", "-compression_level", "8"])
else:
cmd.extend(["-c:a", "aac", "-q:a", "2"])
# Add output path
cmd.append(output_path)
# Create process
proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
# Process segments
try:
# Use the unified streaming function
def process_chunk(chunk_bytes, is_last):
proc.stdin.write(chunk_bytes)
self._stream_audio_in_chunks(audio_segments, process_chunk,
progress_prefix=f"Processing {output_format.upper()}")
# Close stdin and wait for process to complete
proc.stdin.close()
if proc.wait() != 0:
self.log_updated.emit((f"{output_format.upper()} conversion failed.", "red"))
return False, None
return True, output_path
except Exception as e:
self.log_updated.emit((f"Error during {output_format.upper()} conversion: {str(e)}", "red"))
proc.stdin.close()
try:
proc.terminate()
except:
pass
return False, None
# For formats supported by soundfile (mp3, flac)
else:
try:
with sf.SoundFile(output_path, 'w', samplerate=24000, channels=1, format=output_format) as f:
for i, segment in enumerate(audio_segments):
f.write(segment)
return True, output_path
except Exception as e:
self.log_updated.emit((f"Error processing {output_format.upper()} file: {str(e)}", "red"))
return False, None
def run(self):
print(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n"
)
try:
hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg))
# Show configuration
self.log_updated.emit("Configuration:")
# Use display_path for logs if available, otherwise use the actual file name
display_file = self.display_path if self.display_path else self.file_name
self.log_updated.emit(f"- Input File: {display_file}")
# Use file size string passed from GUI
if hasattr(self, "file_size_str"):
self.log_updated.emit(f"- File size: {self.file_size_str}")
self.log_updated.emit(f"- Total characters: {self.total_char_count:,}")
self.log_updated.emit(
f"- Language: {self.lang_code} ({LANGUAGE_DESCRIPTIONS.get(self.lang_code, 'Unknown')})"
)
self.log_updated.emit(f"- Voice: {self.voice}")
self.log_updated.emit(f"- Speed: {self.speed}")
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
self.log_updated.emit(f"- Output format: {self.output_format}")
self.log_updated.emit(f"- Subtitle format: {getattr(self, 'subtitle_format', 'srt')}")
self.log_updated.emit(f"- Save option: {self.save_option}")
if self.replace_single_newlines:
self.log_updated.emit(f"- Replace single newlines: Yes")
# Display save_chapters_separately flag if it's set
if hasattr(self, "save_chapters_separately"):
self.log_updated.emit(
(
f"- Save chapters separately: {'Yes' if self.save_chapters_separately else 'No'}"
)
)
# Display merge_chapters_at_end flag if save_chapters_separately is True
if self.save_chapters_separately:
merge_at_end = getattr(self, "merge_chapters_at_end", True)
self.log_updated.emit(
f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}"
)
# Display the separate chapters format if it's set
separate_format = getattr(self, 'separate_chapters_format', 'wav')
self.log_updated.emit(f"- Separate chapters format: {separate_format}")
if self.save_option == "Choose output folder":
self.log_updated.emit(
f" - Output folder: {self.output_folder or os.getcwd()}"
)
self.log_updated.emit("\nInitializing TTS pipeline...")
# Set device based on use_gpu setting
device = "cuda" if self.use_gpu else "cpu"
tts = self.KPipeline(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
if self.is_direct_text:
text = self.file_name # Treat file_name as direct text input
else:
encoding = detect_encoding(self.file_name)
with open(
self.file_name, "r", encoding=encoding, errors="replace"
) as file:
text = file.read()
# Clean up text using utility function
text = clean_text(text)
# Remove metadata markers from the text to be processed
metadata_pattern = r"<<METADATA_[^:]+:[^>]*>>"
text = re.sub(metadata_pattern, "", text)
# --- Chapter splitting logic ---
chapter_pattern = r"<<CHAPTER_MARKER:(.*?)>>"
chapter_splits = list(re.finditer(chapter_pattern, text))
chapters = []
if chapter_splits:
# prepend Introduction for content before first marker
first_start = chapter_splits[0].start()
if first_start > 0:
intro_text = text[:first_start].strip()
if intro_text:
chapters.append(("Introduction", intro_text))
for idx, match in enumerate(chapter_splits):
start = match.end()
end = (
chapter_splits[idx + 1].start()
if idx + 1 < len(chapter_splits)
else len(text)
)
chapter_name = match.group(1).strip()
chapter_text = text[start:end].strip()
chapters.append((chapter_name, chapter_text))
else:
chapters = [("text", text)]
total_chapters = len(chapters)
# For text files with chapters, prompt user for options if not already set
is_txt_file = not self.is_direct_text and (
self.file_name.lower().endswith(".txt")
or (self.display_path and self.display_path.lower().endswith(".txt"))
)
if (
is_txt_file
and total_chapters > 1
and (
not hasattr(self, "save_chapters_separately")
or not hasattr(self, "merge_chapters_at_end")
)
and not self.chapter_options_set
):
# Emit signal to main thread and wait
self.chapters_detected.emit(total_chapters)
self._chapter_options_event.wait()
if self.cancel_requested:
self.conversion_finished.emit("Cancelled", None)
return
self.chapter_options_set = True
# Log all detected chapters at the beginning
if total_chapters > 1:
chapter_list = "\n".join(
[f"{i+1}) {c[0]}" for i, c in enumerate(chapters)]
)
self.log_updated.emit(
(f"\nDetected chapters ({total_chapters}):\n" + chapter_list)
)
else:
self.log_updated.emit((f"\nProcessing {chapters[0][0]}..."))
# If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file
save_chapters_separately = getattr(self, "save_chapters_separately", False)
chapters_out_dir = None
suffix = ""
base_path = self.display_path if self.display_path else self.file_name
base_name = os.path.splitext(os.path.basename(base_path))[0]
if self.save_option == "Save to Desktop":
parent_dir = user_desktop_dir()
elif self.save_option == "Save next to input file":
parent_dir = os.path.dirname(base_path)
else:
parent_dir = self.output_folder or os.getcwd()
# Ensure the output folder exists, error if it doesn't
if not os.path.exists(parent_dir):
self.log_updated.emit(
(
f"Output folder does not exist: {parent_dir}",
"red",
)
)
# Find a unique suffix for both folder and merged file, always
counter = 1
while True:
suffix = f"_{counter}" if counter > 1 else ""
chapters_out_dir_candidate = os.path.join(
parent_dir, f"{base_name}{suffix}_chapters"
)
merged_file_candidate = os.path.join(
parent_dir, f"{base_name}{suffix}.{self.output_format}"
)
merged_srt_candidate = (
os.path.splitext(merged_file_candidate)[0] + ".srt"
)
if (
not os.path.exists(chapters_out_dir_candidate)
and not os.path.exists(merged_file_candidate)
and (
self.subtitle_mode == "Disabled"
or not os.path.exists(merged_srt_candidate)
)
):
break
counter += 1
if save_chapters_separately and total_chapters > 1:
chapters_out_dir = chapters_out_dir_candidate
os.makedirs(chapters_out_dir, exist_ok=True)
self.log_updated.emit(f"\nChapters output folder: {chapters_out_dir}")
audio_segments = []
subtitle_entries = []
current_time = 0.0
rate = 24000
subtitle_mode = self.subtitle_mode
raw_tts_results = [] # Collect all raw tts Result objects
# ETR timing starts here, after model loading but before processing
self.etr_start_time = time.time()
self.processed_char_count = 0 # Initialize processed character count
# Initialize current segment counter
current_segment = 0
# Initialize chapter times
chapters_time = [
{"chapter": chapter[0], "start": 0.0, "end": 0.0}
for chapter in chapters
]
# Instead of processing the whole text, process by chapter
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
if total_chapters > 1:
self.log_updated.emit(
(
f"\nChapter {chapter_idx}/{total_chapters}: {chapter_name}",
"green",
)
)
# Variables for per-chapter processing when save_chapters_separately is enabled
chapter_audio_segments = []
chapter_subtitle_entries = []
chapter_current_time = 0.0
# chapter start time
chapter_time = chapters_time[chapter_idx - 1]
chapter_time["start"] = current_time
# Set split_pattern to \n+ which will split on one or more newlines
split_pattern = r"\n+"
# Check if the voice is a formula and load it if necessary
if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else:
loaded_voice = self.voice
for result in tts(
chapter_text,
voice=loaded_voice,
speed=self.speed,
split_pattern=split_pattern,
):
# Print the result for debugging
# print(f"Result: {result}")
if self.cancel_requested:
self.conversion_finished.emit("Cancelled", None)
return
current_segment += 1
grapheme_len = len(result.graphemes)
self.processed_char_count += grapheme_len
# Log progress with both character counts and the graphemes content
self.log_updated.emit(
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
)
raw_tts_results.append(result)
chunk_dur = len(result.audio) / rate
chunk_start = current_time
audio_segments.append(result.audio)
# For per-chapter output
if save_chapters_separately and total_chapters > 1:
chapter_audio_segments.append(result.audio)
chapter_chunk_start = chapter_current_time
# Process token timestamps for subtitle generation
if self.subtitle_mode != "Disabled":
tokens_list = getattr(result, "tokens", [])
tokens_with_timestamps = []
chapter_tokens_with_timestamps = []
# Process every token, regardless of text or timestamps
for tok in tokens_list:
tokens_with_timestamps.append(
{
"start": chunk_start + (tok.start_ts or 0),
"end": chunk_start + (tok.end_ts or 0),
"text": tok.text,
"whitespace": tok.whitespace,
}
)
if save_chapters_separately and total_chapters > 1:
chapter_tokens_with_timestamps.append(
{
"start": chapter_chunk_start
+ (tok.start_ts or 0),
"end": chapter_chunk_start + (tok.end_ts or 0),
"text": tok.text,
"whitespace": tok.whitespace,
}
)
# Process tokens according to subtitle mode
# Global subtitle processing
self._process_subtitle_tokens(
tokens_with_timestamps,
subtitle_entries,
self.max_subtitle_words,
)
# Per-chapter subtitle processing if enabled
if save_chapters_separately and total_chapters > 1:
self._process_subtitle_tokens(
chapter_tokens_with_timestamps,
chapter_subtitle_entries,
self.max_subtitle_words,
)
current_time += chunk_dur
# Update chapter_current_time for per-chapter output
if save_chapters_separately and total_chapters > 1:
chapter_current_time += chunk_dur
# Calculate percentage based on characters processed
percent = min(
int(self.processed_char_count / self.total_char_count * 100), 99
)
# Calculate ETR based on characters processed
etr_str = "Estimating..."
chars_done = self.processed_char_count
elapsed = time.time() - self.etr_start_time
# Calculate ETR if enough data is available
if (
chars_done > 0 and elapsed > 0.5
): # Check elapsed > 0.5 to avoid instability
avg_time_per_char = elapsed / chars_done
remaining = self.total_char_count - self.processed_char_count
if remaining > 0:
secs = avg_time_per_char * remaining
h = int(secs // 3600)
m = int((secs % 3600) // 60)
s = int(secs % 60)
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
# Update progress more frequently (after each result)
self.progress_updated.emit(percent, etr_str)
# Update chapter end time
chapter_time["end"] = current_time
# Save the individual chapter output if save_chapters_separately is enabled
if (
save_chapters_separately
and total_chapters > 1
and chapters_out_dir
and chapter_audio_segments
):
# strip any nonalphanumeric or space/dash
sanitized = re.sub(r"[^\w\s\-]", "", chapter_name)
# collapse spaces or dashes to single underscore
sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_")
# enforce max length
MAX_LEN = 80
if len(sanitized) > MAX_LEN:
# Find last word boundary before limit
pos = sanitized[:MAX_LEN].rfind("_")
# Use word boundary if found, otherwise use hard limit
sanitized = sanitized[:pos if pos > 0 else MAX_LEN].rstrip("_")
chapter_filename = f"{chapter_idx:02d}_{sanitized}"
# Use separate_chapters_format
separate_format = getattr(self, 'separate_chapters_format', 'wav')
chapter_out_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{separate_format}"
)
# Process audio segments using the unified function
success, chapter_out_path = self._process_audio_segments(
chapter_audio_segments, chapter_out_path, separate_format, use_ffmpeg=(separate_format in ["opus", "m4b"])
)
if not success:
self.log_updated.emit((f"Failed to write {separate_format.upper()} file.", "red"))
continue
# Generate subtitle file for chapter if not Disabled
if self.subtitle_mode != "Disabled" and chapter_subtitle_entries:
subtitle_format = getattr(self, 'subtitle_format', 'srt')
file_extension = 'ass' if 'ass' in subtitle_format else 'srt'
chapter_subtitle_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{file_extension}"
)
if 'ass' in subtitle_format:
# Generate ASS subtitle
is_centered = 'centered' in subtitle_format
is_narrow = 'narrow' in subtitle_format
self._write_ass_subtitle(chapter_subtitle_path, chapter_subtitle_entries, is_centered, is_narrow)
else:
# Generate SRT subtitle (default)
with open(
chapter_subtitle_path, "w", encoding="utf-8", errors="replace"
) as srt_file:
for i, (start, end, text) in enumerate(
chapter_subtitle_entries, 1
):
srt_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
self.log_updated.emit(
(
f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nSubtitle saved to: {chapter_subtitle_path}",
"green",
)
)
else:
self.log_updated.emit(
(
f"\nChapter {chapter_idx} saved to: {chapter_out_path}",
"green",
)
)
# Set progress to 100% when processing is complete
self.progress_updated.emit(100, "00:00:00")
# Only generate the merged output file if merge_chapters_at_end is True or save_chapters_separately is False
merge_chapters = (
not hasattr(self, "save_chapters_separately")
or not self.save_chapters_separately
or getattr(self, "merge_chapters_at_end", True)
)
intended_output_format = self.output_format # Store the original choice
if audio_segments and merge_chapters:
self.log_updated.emit("\nFinalizing audio file...")
out_dir = parent_dir
base_filepath_no_ext = os.path.join(out_dir, f"{base_name}{suffix}")
final_out_path = None
# Use dedicated chapter processing for M4B when we have chapters
if intended_output_format == "m4b":
self.log_updated.emit("\nGenerating audio with chapters...")
final_out_path = self._generate_m4b_with_chapters(
audio_segments, chapters_time, base_filepath_no_ext
)
else:
# Process audio segments using the unified function
success, final_out_path = self._process_audio_segments(
audio_segments, f"{base_filepath_no_ext}.{intended_output_format}",
intended_output_format,
use_ffmpeg=(intended_output_format in ["opus", "m4b"])
)
if not success:
final_out_path = None
if not final_out_path:
self.log_updated.emit(("Audio generation failed.", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
return
# Subtitle and final message logic
if final_out_path:
if self.subtitle_mode != "Disabled":
subtitle_format = getattr(self, 'subtitle_format', 'srt')
file_extension = 'ass' if 'ass' in subtitle_format else 'srt'
subtitle_path = os.path.splitext(final_out_path)[0] + f".{file_extension}"
if 'ass' in subtitle_format:
# Generate ASS subtitle with optional centering and margin
is_centered = 'centered' in subtitle_format
is_narrow = 'narrow' in subtitle_format
self._write_ass_subtitle(subtitle_path, subtitle_entries, is_centered, is_narrow)
else:
# Generate SRT subtitle (default)
with open(subtitle_path, "w", encoding="utf-8", errors="replace") as srt_file:
for i, (start, end, text) in enumerate(subtitle_entries, 1):
srt_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
self.conversion_finished.emit(
(
f"\nAudiobook saved to: {final_out_path}\n\nSubtitle saved to: {subtitle_path}",
"green",
),
final_out_path,
)
else:
self.conversion_finished.emit(
(f"\nAudiobook saved to: {final_out_path}", "green"), final_out_path
)
else:
self.log_updated.emit(("Audio generation failed (final_out_path was not set).", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
elif audio_segments and not merge_chapters:
self.conversion_finished.emit(
(
f"\nAll chapters processed successfully and saved to: {chapters_out_dir}",
"green",
),
chapters_out_dir,
)
else:
self.log_updated.emit(("No audio segments were generated.", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
except Exception as e:
self.log_updated.emit((f"Error occurred: {str(e)}", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
def set_chapter_options(self, options):
"""Set chapter options from the dialog and resume processing"""
self.save_chapters_separately = options["save_chapters_separately"]
self.merge_chapters_at_end = options["merge_chapters_at_end"]
self.waiting_for_user_input = False
self._chapter_options_event.set()
def _generate_m4b_with_chapters(self, audio_segments, chapters_time, base_filepath_no_ext):
"""Generate M4B file with chapters from audio segments"""
final_wav_path = f"{base_filepath_no_ext}.wav"
output_m4b_path = f"{base_filepath_no_ext}.m4b"
chapters_info_path = f"{base_filepath_no_ext}_chapters.txt"
# Early check for single/no chapter case
if not chapters_time or len(chapters_time) <= 1:
self.log_updated.emit(
(
"\nFile contains only one chapter or no chapters were detected. Audio will be saved as a standard .wav file instead.",
"red",
)
)
# Use the established unified method to create a WAV file
success, wav_path = self._process_audio_segments(
audio_segments, final_wav_path, "wav", use_ffmpeg=False
)
if success:
return wav_path
else:
self.log_updated.emit((f"\nFailed to save single/no chapter audio as WAV", "red"))
return None
try:
# Write chapter metadata file
with open(chapters_info_path, "w", encoding="utf-8") as f:
f.write(";FFMETADATA1\n")
for chapter in chapters_time:
chapter_title = chapter['chapter'].replace('=', '\\=')
f.write(f"[CHAPTER]\n")
f.write(f"TIMEBASE=1/1000\n")
f.write(f"START={int(chapter['start']*1000)}\n")
f.write(f"END={int(chapter['end']*1000)}\n")
f.write(f"title={chapter_title}\n\n")
# For M4B with chapters, we need to use input file for chapter information
static_ffmpeg.add_paths()
metadata_options = self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
# Use pipe-based approach for audio input with special args for M4B chapters
ffmpeg_args = [
"-i", chapters_info_path,
"-map", "0:a",
"-map_metadata", "1",
"-map_chapters", "1",
*metadata_options,
"-c:a", "aac",
"-q:a", "2", # Quality-based VBR for better quality control
"-movflags", "+faststart+use_metadata_tags", # Added for better compatibility
]
# Use the established unified method with M4B-specific args
success, out_path = self._process_audio_segments(
audio_segments, output_m4b_path, "m4b", use_ffmpeg=True, ffmpeg_args=ffmpeg_args
)
# Clean up the temporary chapter metadata file
if os.path.exists(chapters_info_path):
try:
os.remove(chapters_info_path)
except Exception as e:
self.log_updated.emit((f"Warning: Could not delete chapters file: {e}", "orange"))
if success:
return out_path
# If M4B generation failed, fallback to WAV
self.log_updated.emit((f"M4B conversion failed. Falling back to WAV.\n", "red"))
success, wav_path = self._process_audio_segments(
audio_segments, final_wav_path, "wav", use_ffmpeg=False
)
if success:
return wav_path
else:
self.log_updated.emit((f"Failed to write WAV file as fallback.", "red"))
return None
except Exception as e:
# General error during M4B generation - create WAV file directly as final output
self.log_updated.emit((f"Error during M4B generation: {str(e)}.\n\nFalling back to WAV.\n", "red"))
# Use the established unified method to create a WAV file as fallback
success, wav_path = self._process_audio_segments(
audio_segments, final_wav_path, "wav", use_ffmpeg=False
)
# Clean up temp files
if os.path.exists(chapters_info_path):
try:
os.remove(chapters_info_path)
except Exception:
pass
if success:
return wav_path
else:
self.log_updated.emit((f"Critical error: Failed to save WAV fallback", "red"))
return None
def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self):
"""Extract metadata tags from text content and add them to ffmpeg command"""
metadata_options = []
# Get the input text (either direct or from file)
text = ""
if self.is_direct_text:
text = self.file_name
else:
try:
encoding = detect_encoding(self.file_name)
with open(self.file_name, "r", encoding=encoding, errors="replace") as file:
text = file.read()
except Exception as e:
self.log_updated.emit(f"Warning: Could not read file for metadata extraction: {e}")
return []
# Extract metadata tags using regex
title_match = re.search(r"<<METADATA_TITLE:([^>]*)>>", text)
artist_match = re.search(r"<<METADATA_ARTIST:([^>]*)>>", text)
album_match = re.search(r"<<METADATA_ALBUM:([^>]*)>>", text)
year_match = re.search(r"<<METADATA_YEAR:([^>]*)>>", text)
album_artist_match = re.search(r"<<METADATA_ALBUM_ARTIST:([^>]*)>>", text)
composer_match = re.search(r"<<METADATA_COMPOSER:([^>]*)>>", text)
genre_match = re.search(r"<<METADATA_GENRE:([^>]*)>>", text)
# Use display path or filename as fallback for title
filename = os.path.splitext(os.path.basename(self.display_path if self.display_path else self.file_name))[0]
if title_match:
metadata_options.extend(["-metadata", f"title={title_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"title={filename}"])
# Add artist metadata
if artist_match:
metadata_options.extend(["-metadata", f"artist={artist_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"artist=Unknown"])
# Add album metadata
if album_match:
metadata_options.extend(["-metadata", f"album={album_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"album={filename}"])
# Add year metadata
if year_match:
metadata_options.extend(["-metadata", f"date={year_match.group(1)}"])
else:
# Use current year if year is not specified
import datetime
current_year = datetime.datetime.now().year
metadata_options.extend(["-metadata", f"date={current_year}"])
# Add album artist metadata
if album_artist_match:
metadata_options.extend(["-metadata", f"album_artist={album_artist_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"album_artist=Unknown"])
# Add composer metadata
if composer_match:
metadata_options.extend(["-metadata", f"composer={composer_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"composer=Narrator"])
# Add genre metadata
if genre_match:
metadata_options.extend(["-metadata", f"genre={genre_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"genre=Audiobook"])
# Add these to ffmpeg command
return metadata_options
def _srt_time(self, t):
"""Helper function to format time for SRT files"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
ms = int((t - int(t)) * 1000)
return f"{h:02}:{m:02}:{s:02},{ms:03}"
def _ass_time(self, t):
"""Helper function to format time for ASS files"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
cs = int((t - int(t)) * 100) # Centiseconds for ASS format
return f"{h}:{m:02}:{s:02}.{cs:02}"
def _process_subtitle_tokens(
self, tokens_with_timestamps, subtitle_entries, max_subtitle_words
):
"""Helper function to process subtitle tokens according to the subtitle mode"""
if not tokens_with_timestamps:
return
if self.subtitle_mode == "Sentence" or self.subtitle_mode == "Sentence + Comma":
# Define separator pattern based on mode
separator = r"[.!?]" if self.subtitle_mode == "Sentence" else r"[.!?,]"
current_sentence = []
word_count = 0
for token in tokens_with_timestamps:
current_sentence.append(token)
word_count += 1
# Split sentences based on separator or word count
if (
re.search(separator, token["text"]) and token["whitespace"] == " "
) or word_count >= max_subtitle_words:
if current_sentence:
# Create subtitle entry for this sentence
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
# Simplified text joining logic
sentence_text = ""
for t in current_sentence:
sentence_text += t["text"] + (t.get("whitespace", "") or "")
subtitle_entries.append(
(start_time, end_time, sentence_text.strip())
)
current_sentence = []
word_count = 0
# Add any remaining tokens as a sentence
if current_sentence:
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
# Simplified text joining logic
sentence_text = ""
for t in current_sentence:
sentence_text += t["text"] + (t.get("whitespace", "") or "")
subtitle_entries.append((start_time, end_time, sentence_text.strip()))
else:
# Word count-based grouping
try:
word_count = int(self.subtitle_mode.split()[0])
word_count = min(word_count, max_subtitle_words)
except (ValueError, IndexError):
word_count = 1
# Combine punctuation with preceding words
processed_tokens = []
i = 0
while i < len(tokens_with_timestamps):
token = tokens_with_timestamps[i].copy()
# Look ahead for punctuation
while i + 1 < len(tokens_with_timestamps) and re.match(
r"^[^\w\s]+$", tokens_with_timestamps[i + 1]["text"]
):
token["text"] += tokens_with_timestamps[i + 1]["text"]
token["end"] = tokens_with_timestamps[i + 1]["end"]
token["whitespace"] = tokens_with_timestamps[i + 1]["whitespace"]
i += 1
processed_tokens.append(token)
i += 1
# Group words into subtitle entries
for i in range(0, len(processed_tokens), word_count):
group = processed_tokens[i : i + word_count]
if group:
text = "".join(
t["text"] + (t.get("whitespace", "") or "") for t in group
)
subtitle_entries.append(
(group[0]["start"], group[-1]["end"], text.strip())
)
def _write_ass_subtitle(self, file_path, subtitle_entries, is_centered=False, is_narrow=False):
with open(file_path, "w", encoding="utf-8", errors="replace") as f:
# Minimal ASS header
f.write("[Script Info]\n")
f.write("Title: Generated by Abogen\n")
f.write("ScriptType: v4.00+\n\n")
# Only events section, use override tags for positioning
f.write("[Events]\n")
f.write("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n")
# Set margin based on is_narrow parameter
margin = "90" if is_narrow else ""
alignment_tag = ""
if is_centered:
alignment = 5
alignment_tag = f"{{\\an{alignment}}}"
# Write each subtitle with override tag for alignment and margins
for i, (start, end, text) in enumerate(subtitle_entries, 1):
start_time = self._ass_time(start)
end_time = self._ass_time(end)
f.write(f"Dialogue: 0,{start_time},{end_time},Default,,{margin},{margin},0,,{alignment_tag}{text}\n")
def cancel(self):
self.cancel_requested = True
self.waiting_for_user_input = (
False # Also release the wait if we're waiting for input
)
class VoicePreviewThread(QThread):
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(
self,
np_module,
kpipeline_class,
lang_code,
voice,
speed,
use_gpu=False,
parent=None,
):
super().__init__(parent)
self.np_module = np_module
self.kpipeline_class = kpipeline_class
self.lang_code = lang_code
self.voice = voice
self.speed = speed
self.use_gpu = use_gpu
# Cache location for preview audio
self.cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache")
os.makedirs(self.cache_dir, exist_ok=True)
# Calculate cache path
self.cache_path = self._get_cache_path()
def _get_cache_path(self):
"""Generate a unique filename for the voice with its parameters"""
# For a voice formula, use a hash of the formula
if "*" in self.voice:
voice_id = f"voice_formula_{hashlib.md5(self.voice.encode()).hexdigest()[:8]}"
else:
voice_id = self.voice
# Create a unique filename based on voice_id, language, and speed
filename = f"{voice_id}_{self.lang_code}_{self.speed:.2f}.wav"
return os.path.join(self.cache_dir, filename)
def run(self):
print(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
)
# Generate the preview and save to cache
try:
device = "cuda" if self.use_gpu else "cpu"
tts = self.kpipeline_class(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Enable voice formula support for preview
if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else:
loaded_voice = self.voice
sample_text = get_sample_voice_text(self.lang_code)
audio_segments = []
for result in tts(
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
):
audio_segments.append(result.audio)
if audio_segments:
audio = self.np_module.concatenate(audio_segments)
# Save directly to the cache path
sf.write(self.cache_path, audio, 24000)
self.temp_wav = self.cache_path
self.finished.emit()
except Exception as e:
self.error.emit(f"Voice preview error: {str(e)}")
class PlayAudioThread(QThread):
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, wav_path, parent=None):
super().__init__(parent)
self.wav_path = wav_path
self.is_canceled = False
def run(self):
try:
import pygame
import time as _time
pygame.mixer.init()
pygame.mixer.music.load(self.wav_path)
pygame.mixer.music.play()
# Wait until playback is finished or canceled
while pygame.mixer.music.get_busy() and not self.is_canceled:
_time.sleep(0.2)
# Make sure to clean up regardless of how we exited the loop
try:
pygame.mixer.music.stop()
pygame.mixer.music.unload()
pygame.mixer.quit() # Quit the mixer
except Exception:
# Ignore any errors during cleanup
pass
self.finished.emit()
except Exception as e:
# Handle initialization errors separately to give better error messages
if "mixer not initialized" in str(e):
self.error.emit("Audio playback error: The audio system was not properly initialized")
else:
self.error.emit(f"Audio playback error: {str(e)}")
def stop(self):
"""Safely stop playback"""
self.is_canceled = True
# Try to stop pygame if it's running, but catch all exceptions
try:
import pygame
if pygame.mixer.get_init():
if pygame.mixer.music.get_busy():
pygame.mixer.music.stop()
pygame.mixer.music.unload()
except Exception:
# Ignore all errors when stopping since mixer might not be initialized
pass