mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a8df9b34e |
@@ -1,3 +1,7 @@
|
||||
# v1.1.1
|
||||
- Fixed adding wrong file in queue for EPUB and PDF files, ensuring the correct file is added to the queue.
|
||||
- Reformatted the code using Black.
|
||||
|
||||
# v1.1.0
|
||||
- Added queue system for processing multiple items, allowing users to add multiple files and process them in a queue, mentioned by @jborza in #30 (Special thanks to @jborza for implementing this feature in PR #35)
|
||||
- Added a feature that allows selecting multiple items in book handler (in right click menu) by @jborza in #31, that fixes #28
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.0
|
||||
1.1.1
|
||||
+82
-38
@@ -849,7 +849,7 @@ class HandlerDialog(QDialog):
|
||||
if iterator.value():
|
||||
has_parents = True
|
||||
self.treeWidget.setRootIsDecorated(has_parents)
|
||||
|
||||
|
||||
def _update_checkbox_states(self):
|
||||
"""Update the checkbox states based on the current checked chapters."""
|
||||
for i in range(self.treeWidget.topLevelItemCount()):
|
||||
@@ -997,10 +997,21 @@ class HandlerDialog(QDialog):
|
||||
bookmark_item.setData(0, Qt.UserRole, page_id)
|
||||
# only allow checking if this chapter has content
|
||||
if self.content_lengths.get(page_id, 0) > 0:
|
||||
bookmark_item.setFlags(bookmark_item.flags() | Qt.ItemIsUserCheckable)
|
||||
bookmark_item.setCheckState(0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked)
|
||||
bookmark_item.setFlags(
|
||||
bookmark_item.flags() | Qt.ItemIsUserCheckable
|
||||
)
|
||||
bookmark_item.setCheckState(
|
||||
0,
|
||||
(
|
||||
Qt.Checked
|
||||
if page_id in self.checked_chapters
|
||||
else Qt.Unchecked
|
||||
),
|
||||
)
|
||||
else:
|
||||
bookmark_item.setFlags(bookmark_item.flags() & ~Qt.ItemIsUserCheckable)
|
||||
bookmark_item.setFlags(
|
||||
bookmark_item.flags() & ~Qt.ItemIsUserCheckable
|
||||
)
|
||||
# map for uncategorized pages
|
||||
self.bookmark_items_map[page_num] = bookmark_item
|
||||
|
||||
@@ -1027,10 +1038,21 @@ class HandlerDialog(QDialog):
|
||||
page_item.setData(0, Qt.UserRole, page_id)
|
||||
# only allow checking if this sub-page has content
|
||||
if self.content_lengths.get(page_id, 0) > 0:
|
||||
page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable)
|
||||
page_item.setCheckState(0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked)
|
||||
page_item.setFlags(
|
||||
page_item.flags() | Qt.ItemIsUserCheckable
|
||||
)
|
||||
page_item.setCheckState(
|
||||
0,
|
||||
(
|
||||
Qt.Checked
|
||||
if page_id in self.checked_chapters
|
||||
else Qt.Unchecked
|
||||
),
|
||||
)
|
||||
else:
|
||||
page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable)
|
||||
page_item.setFlags(
|
||||
page_item.flags() & ~Qt.ItemIsUserCheckable
|
||||
)
|
||||
|
||||
added_pages.add(sub_page_num)
|
||||
|
||||
@@ -1038,11 +1060,15 @@ class HandlerDialog(QDialog):
|
||||
|
||||
covered_pages = set(added_pages)
|
||||
# attach any pages without direct bookmarks under nearest preceding chapter
|
||||
uncategorized_pages = [i for i in range(len(self.pdf_doc)) if i not in covered_pages]
|
||||
uncategorized_pages = [
|
||||
i for i in range(len(self.pdf_doc)) if i not in covered_pages
|
||||
]
|
||||
for page_num in uncategorized_pages:
|
||||
# find nearest previous bookmark
|
||||
prev_nums = [n for n in sorted(self.bookmark_items_map) if n < page_num]
|
||||
parent_item = self.bookmark_items_map[prev_nums[-1]] if prev_nums else self.treeWidget
|
||||
parent_item = (
|
||||
self.bookmark_items_map[prev_nums[-1]] if prev_nums else self.treeWidget
|
||||
)
|
||||
page_id = f"page_{page_num+1}"
|
||||
title = f"Page {page_num+1}"
|
||||
text = self.content_texts.get(page_id, "").strip()
|
||||
@@ -1055,7 +1081,9 @@ class HandlerDialog(QDialog):
|
||||
# only allow checking if uncategorized page has content
|
||||
if self.content_lengths.get(page_id, 0) > 0:
|
||||
page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable)
|
||||
page_item.setCheckState(0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked)
|
||||
page_item.setCheckState(
|
||||
0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked
|
||||
)
|
||||
else:
|
||||
page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable)
|
||||
|
||||
@@ -1081,7 +1109,9 @@ class HandlerDialog(QDialog):
|
||||
# only allow checking if standalone page has content
|
||||
if self.content_lengths.get(page_id, 0) > 0:
|
||||
page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable)
|
||||
page_item.setCheckState(0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked)
|
||||
page_item.setCheckState(
|
||||
0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked
|
||||
)
|
||||
else:
|
||||
page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable)
|
||||
|
||||
@@ -1182,7 +1212,7 @@ class HandlerDialog(QDialog):
|
||||
self.save_chapters_checkbox = QCheckBox(checkbox_text, self)
|
||||
self.save_chapters_checkbox.setChecked(self.save_chapters_separately)
|
||||
self.save_chapters_checkbox.stateChanged.connect(self.on_save_chapters_changed)
|
||||
leftLayout.addWidget(self.save_chapters_checkbox)
|
||||
leftLayout.addWidget(self.save_chapters_checkbox)
|
||||
self.merge_chapters_checkbox = QCheckBox(
|
||||
"Create a merged version at the end", self
|
||||
)
|
||||
@@ -1191,7 +1221,7 @@ class HandlerDialog(QDialog):
|
||||
self.on_merge_chapters_changed
|
||||
)
|
||||
leftLayout.addWidget(self.merge_chapters_checkbox)
|
||||
|
||||
|
||||
self.save_as_project_checkbox = QCheckBox(
|
||||
"Save in a project folder with metadata", self
|
||||
)
|
||||
@@ -1501,7 +1531,9 @@ class HandlerDialog(QDialog):
|
||||
authors_text = ", ".join(self.book_metadata["authors"])
|
||||
html_content += f"<p style='text-align: center; font-style: italic;'>By {authors_text}</p>"
|
||||
|
||||
if self.book_metadata["publisher"] or self.book_metadata.get("publication_year"):
|
||||
if self.book_metadata["publisher"] or self.book_metadata.get(
|
||||
"publication_year"
|
||||
):
|
||||
pub_info = []
|
||||
if self.book_metadata["publisher"]:
|
||||
pub_info.append(f"Published by {self.book_metadata['publisher']}")
|
||||
@@ -1543,7 +1575,9 @@ class HandlerDialog(QDialog):
|
||||
try:
|
||||
author_items = self.book.get_metadata("DC", "creator")
|
||||
if author_items:
|
||||
metadata["authors"] = [author[0] for author in author_items if len(author) > 0]
|
||||
metadata["authors"] = [
|
||||
author[0] for author in author_items if len(author) > 0
|
||||
]
|
||||
except Exception as e:
|
||||
logging.warning(f"Error extracting author metadata: {e}")
|
||||
|
||||
@@ -1560,14 +1594,14 @@ class HandlerDialog(QDialog):
|
||||
metadata["publisher"] = publisher_items[0][0]
|
||||
except Exception as e:
|
||||
logging.warning(f"Error extracting publisher metadata: {e}")
|
||||
|
||||
|
||||
# Try to extract publication year
|
||||
try:
|
||||
date_items = self.book.get_metadata("DC", "date")
|
||||
if date_items and len(date_items) > 0:
|
||||
date_str = date_items[0][0]
|
||||
# Try to extract just the year from the date string
|
||||
year_match = re.search(r'\b(19|20)\d{2}\b', date_str)
|
||||
year_match = re.search(r"\b(19|20)\d{2}\b", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(0)
|
||||
else:
|
||||
@@ -1603,16 +1637,16 @@ class HandlerDialog(QDialog):
|
||||
metadata["description"] = f"Keywords: {keywords}"
|
||||
|
||||
metadata["publisher"] = pdf_info.get("creator", None)
|
||||
|
||||
|
||||
# Try to extract publication date from PDF metadata
|
||||
if "creationDate" in pdf_info:
|
||||
date_str = pdf_info["creationDate"]
|
||||
year_match = re.search(r'D:(\d{4})', date_str)
|
||||
year_match = re.search(r"D:(\d{4})", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(1)
|
||||
elif "modDate" in pdf_info:
|
||||
date_str = pdf_info["modDate"]
|
||||
year_match = re.search(r'D:(\d{4})', date_str)
|
||||
year_match = re.search(r"D:(\d{4})", date_str)
|
||||
if year_match:
|
||||
metadata["publication_year"] = year_match.group(1)
|
||||
|
||||
@@ -1634,22 +1668,26 @@ class HandlerDialog(QDialog):
|
||||
def _format_metadata_tags(self):
|
||||
"""Format metadata tags for insertion at the beginning of the text"""
|
||||
import datetime
|
||||
|
||||
|
||||
metadata = self.book_metadata
|
||||
filename = os.path.splitext(os.path.basename(self.book_path))[0]
|
||||
current_year = str(datetime.datetime.now().year)
|
||||
|
||||
|
||||
# Get values with fallbacks
|
||||
title = metadata.get("title") or filename
|
||||
authors = metadata.get("authors") or ["Unknown"]
|
||||
authors_text = ", ".join(authors)
|
||||
album_artist = authors_text or "Unknown"
|
||||
year = metadata.get("publication_year") or current_year # Use publication year if available
|
||||
|
||||
year = (
|
||||
metadata.get("publication_year") or current_year
|
||||
) # Use publication year if available
|
||||
|
||||
# Count chapters/pages
|
||||
total_chapters = len(self.checked_chapters)
|
||||
chapter_text = f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}"
|
||||
|
||||
chapter_text = (
|
||||
f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}"
|
||||
)
|
||||
|
||||
# Format metadata tags
|
||||
metadata_tags = [
|
||||
f"<<METADATA_TITLE:{title}>>",
|
||||
@@ -1658,9 +1696,9 @@ class HandlerDialog(QDialog):
|
||||
f"<<METADATA_YEAR:{year}>>",
|
||||
f"<<METADATA_ALBUM_ARTIST:{album_artist}>>",
|
||||
f"<<METADATA_COMPOSER:Narrator>>",
|
||||
f"<<METADATA_GENRE:Audiobook>>"
|
||||
f"<<METADATA_GENRE:Audiobook>>",
|
||||
]
|
||||
|
||||
|
||||
return "\n".join(metadata_tags)
|
||||
|
||||
def _get_epub_selected_text(self):
|
||||
@@ -1669,7 +1707,7 @@ class HandlerDialog(QDialog):
|
||||
|
||||
# Add metadata tags at the beginning
|
||||
metadata_tags = self._format_metadata_tags()
|
||||
|
||||
|
||||
item_order_counter = 0
|
||||
ordered_checked_items = []
|
||||
|
||||
@@ -1730,7 +1768,10 @@ class HandlerDialog(QDialog):
|
||||
if text:
|
||||
all_content.append(text)
|
||||
included_text_ids.add(page_id)
|
||||
return metadata_tags + "\n\n" + "\n\n".join(all_content), all_checked_identifiers
|
||||
return (
|
||||
metadata_tags + "\n\n" + "\n\n".join(all_content),
|
||||
all_checked_identifiers,
|
||||
)
|
||||
|
||||
iterator = QTreeWidgetItemIterator(self.treeWidget)
|
||||
while iterator.value():
|
||||
@@ -1788,7 +1829,10 @@ class HandlerDialog(QDialog):
|
||||
included_text_ids.add(identifier)
|
||||
iterator += 1
|
||||
|
||||
return metadata_tags + "\n\n" + "\n\n".join([t[1] for t in section_titles]), all_checked_identifiers
|
||||
return (
|
||||
metadata_tags + "\n\n" + "\n\n".join([t[1] for t in section_titles]),
|
||||
all_checked_identifiers,
|
||||
)
|
||||
|
||||
def on_save_chapters_changed(self, state):
|
||||
self.save_chapters_separately = bool(state)
|
||||
@@ -1815,21 +1859,21 @@ class HandlerDialog(QDialog):
|
||||
|
||||
def get_save_as_project(self):
|
||||
return self.save_as_project
|
||||
|
||||
|
||||
def check_selected_items(self):
|
||||
self.set_selected_items_checked(True)
|
||||
|
||||
|
||||
def uncheck_selected_items(self):
|
||||
self.set_selected_items_checked(False)
|
||||
|
||||
|
||||
def set_selected_items_checked(self, state: bool):
|
||||
print(f"Checking selected items: {state}")
|
||||
self.treeWidget.blockSignals(True)
|
||||
for item in self.treeWidget.selectedItems():
|
||||
if item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setCheckState(0, Qt.Checked if state else Qt.Unchecked)
|
||||
if item.flags() & Qt.ItemIsUserCheckable:
|
||||
item.setCheckState(0, Qt.Checked if state else Qt.Unchecked)
|
||||
self.treeWidget.blockSignals(False)
|
||||
self._update_checked_set_from_tree()
|
||||
self._update_checked_set_from_tree()
|
||||
|
||||
def on_tree_context_menu(self, pos):
|
||||
item = self.treeWidget.itemAt(pos)
|
||||
@@ -1842,7 +1886,7 @@ class HandlerDialog(QDialog):
|
||||
action.triggered.connect(self.uncheck_selected_items)
|
||||
menu.exec_(self.treeWidget.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
|
||||
if (
|
||||
not item
|
||||
or item.childCount() == 0
|
||||
|
||||
+1
-1
@@ -129,4 +129,4 @@ COLORS = {
|
||||
"DARK_DISABLED": "#535353",
|
||||
"LIGHT_BG": "#eff0f1",
|
||||
"LIGHT_DISABLED": "#9a9999",
|
||||
}
|
||||
}
|
||||
|
||||
+281
-141
@@ -10,13 +10,20 @@ from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
|
||||
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
|
||||
import soundfile as sf
|
||||
from abogen.utils import clean_text, create_process
|
||||
from abogen.constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS, COLORS, CHAPTER_OPTIONS_COUNTDOWN
|
||||
from abogen.constants import (
|
||||
PROGRAM_NAME,
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SAMPLE_VOICE_TEXTS,
|
||||
COLORS,
|
||||
CHAPTER_OPTIONS_COUNTDOWN,
|
||||
)
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
import abogen.hf_tracker as 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"])
|
||||
|
||||
@@ -73,7 +80,9 @@ class ChapterOptionsDialog(QDialog):
|
||||
|
||||
# Countdown label
|
||||
self.countdown_seconds = CHAPTER_OPTIONS_COUNTDOWN
|
||||
self.countdown_label = QLabel(f"Auto-accepting in {self.countdown_seconds} seconds...")
|
||||
self.countdown_label = QLabel(
|
||||
f"Auto-accepting in {self.countdown_seconds} seconds..."
|
||||
)
|
||||
self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};")
|
||||
layout.addWidget(self.countdown_label)
|
||||
|
||||
@@ -96,7 +105,9 @@ class ChapterOptionsDialog(QDialog):
|
||||
def _on_timer_tick(self):
|
||||
self.countdown_seconds -= 1
|
||||
if self.countdown_seconds > 0:
|
||||
self.countdown_label.setText(f"Auto-accepting in {self.countdown_seconds} seconds...")
|
||||
self.countdown_label.setText(
|
||||
f"Auto-accepting in {self.countdown_seconds} seconds..."
|
||||
)
|
||||
else:
|
||||
self._timer.stop()
|
||||
self._button_box.accepted.emit() # Simulate OK click
|
||||
@@ -152,7 +163,7 @@ class ConversionThread(QThread):
|
||||
start_time,
|
||||
total_char_count,
|
||||
use_gpu=True,
|
||||
from_queue=False
|
||||
from_queue=False,
|
||||
): # Add use_gpu parameter
|
||||
super().__init__()
|
||||
self._chapter_options_event = threading.Event()
|
||||
@@ -182,15 +193,17 @@ class ConversionThread(QThread):
|
||||
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"):
|
||||
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
|
||||
"""
|
||||
@@ -199,83 +212,98 @@ class ConversionThread(QThread):
|
||||
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'):
|
||||
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)
|
||||
|
||||
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)")
|
||||
|
||||
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):
|
||||
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:
|
||||
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"
|
||||
"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)
|
||||
@@ -289,48 +317,62 @@ class ConversionThread(QThread):
|
||||
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()}")
|
||||
|
||||
|
||||
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"))
|
||||
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"))
|
||||
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:
|
||||
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"))
|
||||
self.log_updated.emit(
|
||||
(f"Error processing {output_format.upper()} file: {str(e)}", "red")
|
||||
)
|
||||
return False, None
|
||||
|
||||
def run(self):
|
||||
@@ -341,12 +383,14 @@ class ConversionThread(QThread):
|
||||
hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg))
|
||||
# Show configuration
|
||||
self.log_updated.emit("Configuration:")
|
||||
|
||||
|
||||
# Use file_name for logs if from_queue, otherwise use display_path if available
|
||||
if getattr(self, "from_queue", False):
|
||||
display_file = self.file_name
|
||||
else:
|
||||
display_file = self.display_path if self.display_path else self.file_name
|
||||
display_file = (
|
||||
self.display_path if self.display_path else self.file_name
|
||||
)
|
||||
|
||||
self.log_updated.emit(f"- Input File: {display_file}")
|
||||
|
||||
@@ -363,7 +407,9 @@ class ConversionThread(QThread):
|
||||
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"- 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")
|
||||
@@ -382,8 +428,10 @@ class ConversionThread(QThread):
|
||||
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}")
|
||||
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(
|
||||
@@ -408,7 +456,7 @@ class ConversionThread(QThread):
|
||||
|
||||
# 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)
|
||||
@@ -478,7 +526,6 @@ class ConversionThread(QThread):
|
||||
chapters_out_dir = None
|
||||
suffix = ""
|
||||
|
||||
|
||||
# Use file_name for logs if from_queue, otherwise use display_path if available
|
||||
if getattr(self, "from_queue", False):
|
||||
base_path = self.file_name
|
||||
@@ -701,42 +748,61 @@ class ConversionThread(QThread):
|
||||
# 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("_")
|
||||
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')
|
||||
|
||||
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"])
|
||||
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"))
|
||||
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'
|
||||
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:
|
||||
|
||||
if "ass" in subtitle_format:
|
||||
# Generate ASS subtitle
|
||||
is_centered = subtitle_format in ("ass_centered_wide", "ass_centered_narrow")
|
||||
is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow")
|
||||
self._write_ass_subtitle(chapter_subtitle_path, chapter_subtitle_entries, is_centered, is_narrow)
|
||||
is_centered = subtitle_format in (
|
||||
"ass_centered_wide",
|
||||
"ass_centered_narrow",
|
||||
)
|
||||
is_narrow = subtitle_format in (
|
||||
"ass_narrow",
|
||||
"ass_centered_narrow",
|
||||
)
|
||||
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"
|
||||
chapter_subtitle_path,
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
) as srt_file:
|
||||
for i, (start, end, text) in enumerate(
|
||||
chapter_subtitle_entries, 1
|
||||
@@ -768,8 +834,8 @@ class ConversionThread(QThread):
|
||||
or not self.save_chapters_separately
|
||||
or getattr(self, "merge_chapters_at_end", True)
|
||||
)
|
||||
|
||||
intended_output_format = self.output_format # Store the original choice
|
||||
|
||||
intended_output_format = self.output_format # Store the original choice
|
||||
|
||||
if audio_segments and merge_chapters:
|
||||
self.log_updated.emit("\nFinalizing audio file...")
|
||||
@@ -778,7 +844,7 @@ class ConversionThread(QThread):
|
||||
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...")
|
||||
@@ -788,35 +854,52 @@ class ConversionThread(QThread):
|
||||
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"])
|
||||
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)
|
||||
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:
|
||||
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
|
||||
is_centered = subtitle_format in ("ass_centered_wide", "ass_centered_narrow")
|
||||
is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow")
|
||||
self._write_ass_subtitle(subtitle_path, subtitle_entries, is_centered, is_narrow)
|
||||
is_centered = subtitle_format in (
|
||||
"ass_centered_wide",
|
||||
"ass_centered_narrow",
|
||||
)
|
||||
is_narrow = subtitle_format in (
|
||||
"ass_narrow",
|
||||
"ass_centered_narrow",
|
||||
)
|
||||
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):
|
||||
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"
|
||||
)
|
||||
@@ -829,11 +912,16 @@ class ConversionThread(QThread):
|
||||
)
|
||||
else:
|
||||
self.conversion_finished.emit(
|
||||
(f"\nAudiobook saved to: {final_out_path}", "green"), final_out_path
|
||||
(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)
|
||||
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(
|
||||
(
|
||||
@@ -856,12 +944,14 @@ class ConversionThread(QThread):
|
||||
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):
|
||||
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(
|
||||
@@ -877,7 +967,9 @@ class ConversionThread(QThread):
|
||||
if success:
|
||||
return wav_path
|
||||
else:
|
||||
self.log_updated.emit((f"\nFailed to save single/no chapter audio as WAV", "red"))
|
||||
self.log_updated.emit(
|
||||
(f"\nFailed to save single/no chapter audio as WAV", "red")
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -885,46 +977,61 @@ class ConversionThread(QThread):
|
||||
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('=', '\\=')
|
||||
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",
|
||||
"-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
|
||||
"-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
|
||||
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"))
|
||||
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"))
|
||||
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
|
||||
)
|
||||
@@ -936,30 +1043,37 @@ class ConversionThread(QThread):
|
||||
|
||||
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"))
|
||||
|
||||
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"))
|
||||
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:
|
||||
@@ -967,64 +1081,77 @@ class ConversionThread(QThread):
|
||||
else:
|
||||
try:
|
||||
encoding = detect_encoding(self.file_name)
|
||||
with open(self.file_name, "r", encoding=encoding, errors="replace") as file:
|
||||
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}")
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
# Use file_name for logs if from_queue, otherwise use display_path if available
|
||||
if getattr(self, "from_queue", False):
|
||||
filename = os.path.splitext(os.path.basename(self.file_name))[0]
|
||||
else:
|
||||
filename = os.path.splitext(os.path.basename(self.display_path if self.display_path else self.file_name))[0]
|
||||
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)}"])
|
||||
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)}"])
|
||||
metadata_options.extend(
|
||||
["-metadata", f"composer={composer_match.group(1)}"]
|
||||
)
|
||||
else:
|
||||
metadata_options.extend(["-metadata", f"composer=Narrator"])
|
||||
|
||||
@@ -1033,7 +1160,7 @@ class ConversionThread(QThread):
|
||||
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
|
||||
|
||||
@@ -1044,7 +1171,7 @@ class ConversionThread(QThread):
|
||||
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)
|
||||
@@ -1139,17 +1266,21 @@ class ConversionThread(QThread):
|
||||
(group[0]["start"], group[-1]["end"], text.strip())
|
||||
)
|
||||
|
||||
def _write_ass_subtitle(self, file_path, subtitle_entries, is_centered=False, is_narrow=False):
|
||||
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")
|
||||
|
||||
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 = ""
|
||||
@@ -1161,7 +1292,9 @@ class ConversionThread(QThread):
|
||||
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")
|
||||
f.write(
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{margin},{margin},0,,{alignment_tag}{text}\n"
|
||||
)
|
||||
|
||||
def cancel(self):
|
||||
self.cancel_requested = True
|
||||
@@ -1196,11 +1329,13 @@ class VoicePreviewThread(QThread):
|
||||
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")
|
||||
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()
|
||||
|
||||
@@ -1208,10 +1343,12 @@ class VoicePreviewThread(QThread):
|
||||
"""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]}"
|
||||
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)
|
||||
@@ -1220,7 +1357,7 @@ class VoicePreviewThread(QThread):
|
||||
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"
|
||||
@@ -1268,7 +1405,7 @@ class PlayAudioThread(QThread):
|
||||
# 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()
|
||||
@@ -1277,21 +1414,24 @@ class PlayAudioThread(QThread):
|
||||
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")
|
||||
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()
|
||||
|
||||
+335
-148
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -1,16 +1,20 @@
|
||||
log_callback = None
|
||||
show_warning_signal_emitter = None # Renamed for clarity
|
||||
|
||||
|
||||
def set_log_callback(cb):
|
||||
global log_callback
|
||||
log_callback = cb
|
||||
|
||||
|
||||
def set_show_warning_signal_emitter(emitter): # Renamed for clarity
|
||||
global show_warning_signal_emitter
|
||||
show_warning_signal_emitter = emitter
|
||||
|
||||
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
|
||||
def tracked_hf_hub_download(*args, **kwargs):
|
||||
try:
|
||||
local_kwargs = dict(kwargs)
|
||||
@@ -22,7 +26,10 @@ def tracked_hf_hub_download(*args, **kwargs):
|
||||
if filename.endswith(".pth"):
|
||||
msg = f"\nDownloading model '{filename}' from Hugging Face ({repo_id}). This may take a while. Please wait..."
|
||||
if show_warning_signal_emitter: # Check if the emitter is set
|
||||
show_warning_signal_emitter.emit("Downloading Model", f"Downloading model '{filename}' from Hugging Face repository '{repo_id}'. This may take a while, please wait.")
|
||||
show_warning_signal_emitter.emit(
|
||||
"Downloading Model",
|
||||
f"Downloading model '{filename}' from Hugging Face repository '{repo_id}'. This may take a while, please wait.",
|
||||
)
|
||||
else:
|
||||
msg = f"\nDownloading '{filename}' from Hugging Face ({repo_id}). Please wait..."
|
||||
if log_callback:
|
||||
@@ -32,5 +39,7 @@ def tracked_hf_hub_download(*args, **kwargs):
|
||||
print(msg, flush=True)
|
||||
return hf_hub_download(*args, **kwargs)
|
||||
|
||||
|
||||
import huggingface_hub
|
||||
huggingface_hub.hf_hub_download = tracked_hf_hub_download
|
||||
|
||||
huggingface_hub.hf_hub_download = tracked_hf_hub_download
|
||||
|
||||
+4
-2
@@ -1,20 +1,22 @@
|
||||
import gpustat
|
||||
|
||||
|
||||
def check():
|
||||
try:
|
||||
stats = gpustat.new_query()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
nvidia_keywords = ['nvidia', 'rtx', 'gtx', 'quadro', 'tesla', 'titan', 'mx']
|
||||
nvidia_keywords = ["nvidia", "rtx", "gtx", "quadro", "tesla", "titan", "mx"]
|
||||
for gpu in stats.gpus:
|
||||
name = gpu.name.lower()
|
||||
if any(keyword in name for keyword in nvidia_keywords):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
stats = gpustat.new_query()
|
||||
for gpu in stats.gpus:
|
||||
print(gpu.name)
|
||||
print(check())
|
||||
print(check())
|
||||
|
||||
@@ -25,6 +25,7 @@ if sys.stderr is None:
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
||||
|
||||
|
||||
# Custom message handler to filter out specific Qt warnings
|
||||
def qt_message_handler(mode, context, message):
|
||||
if "Wayland does not support QWindow::requestActivate()" in message:
|
||||
|
||||
+114
-52
@@ -20,6 +20,7 @@ from abogen.constants import COLORS
|
||||
from copy import deepcopy
|
||||
from PyQt5.QtGui import QFontMetrics
|
||||
|
||||
|
||||
class ElidedLabel(QLabel):
|
||||
def __init__(self, text, parent=None):
|
||||
super().__init__(text, parent)
|
||||
@@ -40,6 +41,7 @@ class ElidedLabel(QLabel):
|
||||
def fullText(self):
|
||||
return self._full_text
|
||||
|
||||
|
||||
class QueueListItemWidget(QWidget):
|
||||
def __init__(self, file_name, char_count):
|
||||
super().__init__()
|
||||
@@ -47,6 +49,7 @@ class QueueListItemWidget(QWidget):
|
||||
layout.setContentsMargins(12, 0, 6, 0)
|
||||
layout.setSpacing(0)
|
||||
import os
|
||||
|
||||
name_label = ElidedLabel(os.path.basename(file_name))
|
||||
char_label = QLabel(f"Chars: {char_count}")
|
||||
char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};")
|
||||
@@ -56,6 +59,7 @@ class QueueListItemWidget(QWidget):
|
||||
layout.addWidget(char_label, 0)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class DroppableQueueListWidget(QListWidget):
|
||||
def __init__(self, parent_dialog):
|
||||
super().__init__()
|
||||
@@ -73,7 +77,7 @@ class DroppableQueueListWidget(QListWidget):
|
||||
def dragEnterEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt'):
|
||||
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"):
|
||||
self.drag_overlay.resize(self.size())
|
||||
self.drag_overlay.setVisible(True)
|
||||
event.acceptProposedAction()
|
||||
@@ -84,7 +88,7 @@ class DroppableQueueListWidget(QListWidget):
|
||||
def dragMoveEvent(self, event):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt'):
|
||||
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
@@ -96,7 +100,11 @@ class DroppableQueueListWidget(QListWidget):
|
||||
def dropEvent(self, event):
|
||||
self.drag_overlay.setVisible(False)
|
||||
if event.mimeData().hasUrls():
|
||||
file_paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt')]
|
||||
file_paths = [
|
||||
url.toLocalFile()
|
||||
for url in event.mimeData().urls()
|
||||
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt")
|
||||
]
|
||||
if file_paths:
|
||||
self.parent_dialog.add_files_from_paths(file_paths)
|
||||
event.acceptProposedAction()
|
||||
@@ -107,14 +115,17 @@ class DroppableQueueListWidget(QListWidget):
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, 'drag_overlay'):
|
||||
if hasattr(self, "drag_overlay"):
|
||||
self.drag_overlay.resize(self.size())
|
||||
|
||||
|
||||
class QueueManager(QDialog):
|
||||
def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)):
|
||||
super().__init__()
|
||||
self.queue = queue
|
||||
self._original_queue = deepcopy(queue) # Store a deep copy of the original queue
|
||||
self._original_queue = deepcopy(
|
||||
queue
|
||||
) # Store a deep copy of the original queue
|
||||
self.parent = parent
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(15, 15, 15, 15) # set main layout margins
|
||||
@@ -141,10 +152,12 @@ class QueueManager(QDialog):
|
||||
# Overlay label for empty queue
|
||||
self.empty_overlay = QLabel(
|
||||
"Drag and drop your text files here or use the 'Add files' button.",
|
||||
self.listwidget
|
||||
self.listwidget,
|
||||
)
|
||||
self.empty_overlay.setAlignment(Qt.AlignCenter)
|
||||
self.empty_overlay.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;")
|
||||
self.empty_overlay.setStyleSheet(
|
||||
f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;"
|
||||
)
|
||||
self.empty_overlay.setWordWrap(True)
|
||||
self.empty_overlay.setAttribute(Qt.WA_TransparentForMouseEvents, True)
|
||||
self.empty_overlay.hide()
|
||||
@@ -208,13 +221,14 @@ class QueueManager(QDialog):
|
||||
file_name = item.file_name
|
||||
display_name = file_name
|
||||
import os
|
||||
|
||||
if os.path.sep in file_name:
|
||||
display_name = os.path.basename(file_name)
|
||||
# Get icon for the file
|
||||
icon = icon_provider.icon(QFileInfo(file_name))
|
||||
list_item = QListWidgetItem()
|
||||
# Set tooltip with detailed info
|
||||
output_folder = getattr(item, 'output_folder', '')
|
||||
output_folder = getattr(item, "output_folder", "")
|
||||
tooltip = (
|
||||
f"<b>Path:</b> {file_name}<br>"
|
||||
f"<b>Language:</b> {getattr(item, 'lang_code', '')}<br>"
|
||||
@@ -222,7 +236,7 @@ class QueueManager(QDialog):
|
||||
f"<b>Voice:</b> {getattr(item, 'voice', '')}<br>"
|
||||
f"<b>Save Option:</b> {getattr(item, 'save_option', '')}<br>"
|
||||
)
|
||||
if output_folder not in (None, '', 'None'):
|
||||
if output_folder not in (None, "", "None"):
|
||||
tooltip += f"<b>Output Folder:</b> {output_folder}<br>"
|
||||
tooltip += (
|
||||
f"<b>Subtitle Mode:</b> {getattr(item, 'subtitle_mode', '')}<br>"
|
||||
@@ -233,7 +247,7 @@ class QueueManager(QDialog):
|
||||
list_item.setToolTip(tooltip)
|
||||
list_item.setIcon(icon)
|
||||
# Use custom widget for display
|
||||
char_count = getattr(item, 'total_char_count', 0)
|
||||
char_count = getattr(item, "total_char_count", 0)
|
||||
widget = QueueListItemWidget(file_name, char_count)
|
||||
self.listwidget.addItem(list_item)
|
||||
self.listwidget.setItemWidget(list_item, widget)
|
||||
@@ -244,6 +258,7 @@ class QueueManager(QDialog):
|
||||
if not items:
|
||||
return
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
# Remove by index to ensure correct mapping
|
||||
rows = sorted([self.listwidget.row(item) for item in items], reverse=True)
|
||||
# Warn user if removing multiple files
|
||||
@@ -253,7 +268,7 @@ class QueueManager(QDialog):
|
||||
"Confirm Remove",
|
||||
f"Are you sure you want to remove {len(rows)} selected items from the queue?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
@@ -265,19 +280,22 @@ class QueueManager(QDialog):
|
||||
|
||||
def clear_queue(self):
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
if len(self.queue) > 1:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Confirm Clear Queue",
|
||||
f"Are you sure you want to clear {len(self.queue)} items from the queue?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
self.queue.clear()
|
||||
self.listwidget.clear()
|
||||
self.empty_overlay.resize(self.listwidget.size()) # Ensure overlay is sized correctly
|
||||
self.empty_overlay.resize(
|
||||
self.listwidget.size()
|
||||
) # Ensure overlay is sized correctly
|
||||
self.empty_overlay.show() # Show the overlay when queue is empty
|
||||
self.update_button_states()
|
||||
|
||||
@@ -290,56 +308,74 @@ class QueueManager(QDialog):
|
||||
parent = self.parent
|
||||
if parent is not None:
|
||||
# lang_code: use parent's get_voice_formula and get_selected_lang
|
||||
if hasattr(parent, 'get_voice_formula') and hasattr(parent, 'get_selected_lang'):
|
||||
if hasattr(parent, "get_voice_formula") and hasattr(
|
||||
parent, "get_selected_lang"
|
||||
):
|
||||
voice_formula = parent.get_voice_formula()
|
||||
attrs['lang_code'] = parent.get_selected_lang(voice_formula)
|
||||
attrs['voice'] = voice_formula
|
||||
attrs["lang_code"] = parent.get_selected_lang(voice_formula)
|
||||
attrs["voice"] = voice_formula
|
||||
else:
|
||||
attrs['lang_code'] = getattr(parent, 'selected_lang', '')
|
||||
attrs['voice'] = getattr(parent, 'selected_voice', '')
|
||||
attrs["lang_code"] = getattr(parent, "selected_lang", "")
|
||||
attrs["voice"] = getattr(parent, "selected_voice", "")
|
||||
# speed
|
||||
if hasattr(parent, 'speed_slider'):
|
||||
attrs['speed'] = parent.speed_slider.value() / 100.0
|
||||
if hasattr(parent, "speed_slider"):
|
||||
attrs["speed"] = parent.speed_slider.value() / 100.0
|
||||
else:
|
||||
attrs['speed'] = getattr(parent, 'speed', 1.0)
|
||||
attrs["speed"] = getattr(parent, "speed", 1.0)
|
||||
# save_option
|
||||
attrs['save_option'] = getattr(parent, 'save_option', '')
|
||||
attrs["save_option"] = getattr(parent, "save_option", "")
|
||||
# output_folder
|
||||
attrs['output_folder'] = getattr(parent, 'selected_output_folder', '')
|
||||
attrs["output_folder"] = getattr(parent, "selected_output_folder", "")
|
||||
# subtitle_mode
|
||||
if hasattr(parent, 'get_actual_subtitle_mode'):
|
||||
attrs['subtitle_mode'] = parent.get_actual_subtitle_mode()
|
||||
if hasattr(parent, "get_actual_subtitle_mode"):
|
||||
attrs["subtitle_mode"] = parent.get_actual_subtitle_mode()
|
||||
else:
|
||||
attrs['subtitle_mode'] = getattr(parent, 'subtitle_mode', '')
|
||||
attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "")
|
||||
# output_format
|
||||
attrs['output_format'] = getattr(parent, 'selected_format', '')
|
||||
attrs["output_format"] = getattr(parent, "selected_format", "")
|
||||
# total_char_count
|
||||
attrs['total_char_count'] = getattr(parent, 'char_count', '')
|
||||
attrs["total_char_count"] = getattr(parent, "char_count", "")
|
||||
# replace_single_newlines
|
||||
attrs['replace_single_newlines'] = getattr(parent, 'replace_single_newlines', False)
|
||||
attrs["replace_single_newlines"] = getattr(
|
||||
parent, "replace_single_newlines", False
|
||||
)
|
||||
else:
|
||||
# fallback: empty values
|
||||
attrs = {k: '' for k in [
|
||||
'lang_code', 'speed', 'voice', 'save_option',
|
||||
'output_folder', 'subtitle_mode', 'output_format', 'total_char_count', 'replace_single_newlines']}
|
||||
attrs = {
|
||||
k: ""
|
||||
for k in [
|
||||
"lang_code",
|
||||
"speed",
|
||||
"voice",
|
||||
"save_option",
|
||||
"output_folder",
|
||||
"subtitle_mode",
|
||||
"output_format",
|
||||
"total_char_count",
|
||||
"replace_single_newlines",
|
||||
]
|
||||
}
|
||||
return attrs
|
||||
|
||||
def add_files_from_paths(self, file_paths):
|
||||
from abogen.utils import calculate_text_length
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
import os
|
||||
|
||||
current_attrs = self.get_current_attributes()
|
||||
duplicates = []
|
||||
for file_path in file_paths:
|
||||
|
||||
class QueueItem:
|
||||
pass
|
||||
|
||||
item = QueueItem()
|
||||
item.file_name = file_path
|
||||
for attr, value in current_attrs.items():
|
||||
setattr(item, attr, value)
|
||||
# Read file content and calculate total_char_count using calculate_text_length
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
file_content = f.read()
|
||||
item.total_char_count = calculate_text_length(file_content)
|
||||
except Exception:
|
||||
@@ -348,16 +384,26 @@ class QueueManager(QDialog):
|
||||
is_duplicate = False
|
||||
for queued_item in self.queue:
|
||||
if (
|
||||
getattr(queued_item, 'file_name', None) == getattr(item, 'file_name', None) and
|
||||
getattr(queued_item, 'lang_code', None) == getattr(item, 'lang_code', None) and
|
||||
getattr(queued_item, 'speed', None) == getattr(item, 'speed', None) and
|
||||
getattr(queued_item, 'voice', None) == getattr(item, 'voice', None) and
|
||||
getattr(queued_item, 'save_option', None) == getattr(item, 'save_option', None) and
|
||||
getattr(queued_item, 'output_folder', None) == getattr(item, 'output_folder', None) and
|
||||
getattr(queued_item, 'subtitle_mode', None) == getattr(item, 'subtitle_mode', None) and
|
||||
getattr(queued_item, 'output_format', None) == getattr(item, 'output_format', None) and
|
||||
getattr(queued_item, 'total_char_count', None) == getattr(item, 'total_char_count', None) and
|
||||
getattr(queued_item, 'replace_single_newlines', False) == getattr(item, 'replace_single_newlines', False)
|
||||
getattr(queued_item, "file_name", None)
|
||||
== getattr(item, "file_name", None)
|
||||
and getattr(queued_item, "lang_code", None)
|
||||
== getattr(item, "lang_code", None)
|
||||
and getattr(queued_item, "speed", None)
|
||||
== getattr(item, "speed", None)
|
||||
and getattr(queued_item, "voice", None)
|
||||
== getattr(item, "voice", None)
|
||||
and getattr(queued_item, "save_option", None)
|
||||
== getattr(item, "save_option", None)
|
||||
and getattr(queued_item, "output_folder", None)
|
||||
== getattr(item, "output_folder", None)
|
||||
and getattr(queued_item, "subtitle_mode", None)
|
||||
== getattr(item, "subtitle_mode", None)
|
||||
and getattr(queued_item, "output_format", None)
|
||||
== getattr(item, "output_format", None)
|
||||
and getattr(queued_item, "total_char_count", None)
|
||||
== getattr(item, "total_char_count", None)
|
||||
and getattr(queued_item, "replace_single_newlines", False)
|
||||
== getattr(item, "replace_single_newlines", False)
|
||||
):
|
||||
is_duplicate = True
|
||||
break
|
||||
@@ -369,7 +415,7 @@ class QueueManager(QDialog):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Duplicate Item(s)",
|
||||
f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue."
|
||||
f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.",
|
||||
)
|
||||
self.process_queue()
|
||||
self.update_button_states()
|
||||
@@ -377,20 +423,23 @@ class QueueManager(QDialog):
|
||||
def add_more_files(self):
|
||||
from PyQt5.QtWidgets import QFileDialog
|
||||
from abogen.utils import calculate_text_length # import the function
|
||||
|
||||
# Only allow .txt files
|
||||
files, _ = QFileDialog.getOpenFileNames(self, "Select .txt files", "", "Text Files (*.txt)")
|
||||
files, _ = QFileDialog.getOpenFileNames(
|
||||
self, "Select .txt files", "", "Text Files (*.txt)"
|
||||
)
|
||||
if not files:
|
||||
return
|
||||
self.add_files_from_paths(files)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, 'empty_overlay'):
|
||||
if hasattr(self, "empty_overlay"):
|
||||
self.empty_overlay.resize(self.listwidget.size())
|
||||
|
||||
def update_button_states(self):
|
||||
# Enable Remove if at least one item is selected, else disable
|
||||
if hasattr(self, 'remove_button'):
|
||||
if hasattr(self, "remove_button"):
|
||||
selected_count = len(self.listwidget.selectedItems())
|
||||
self.remove_button.setEnabled(selected_count > 0)
|
||||
if selected_count > 1:
|
||||
@@ -398,7 +447,7 @@ class QueueManager(QDialog):
|
||||
else:
|
||||
self.remove_button.setText("Remove selected")
|
||||
# Disable Clear if queue is empty
|
||||
if hasattr(self, 'clear_button'):
|
||||
if hasattr(self, "clear_button"):
|
||||
self.clear_button.setEnabled(bool(self.queue))
|
||||
|
||||
def show_context_menu(self, pos):
|
||||
@@ -406,6 +455,7 @@ class QueueManager(QDialog):
|
||||
from PyQt5.QtGui import QDesktopServices
|
||||
from PyQt5.QtCore import QUrl
|
||||
import os
|
||||
|
||||
global_pos = self.listwidget.viewport().mapToGlobal(pos)
|
||||
selected_items = self.listwidget.selectedItems()
|
||||
menu = QMenu(self)
|
||||
@@ -417,35 +467,45 @@ class QueueManager(QDialog):
|
||||
|
||||
# Add Open file action
|
||||
open_file_action = QAction("Open file", self)
|
||||
|
||||
def open_file():
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
item = selected_items[0]
|
||||
display_name = item.text()
|
||||
for q in self.queue:
|
||||
if os.path.basename(q.file_name) == display_name:
|
||||
if not os.path.exists(q.file_name):
|
||||
QMessageBox.warning(self, "File Not Found", f"The file does not exist.")
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", f"The file does not exist."
|
||||
)
|
||||
return
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(q.file_name))
|
||||
break
|
||||
|
||||
open_file_action.triggered.connect(open_file)
|
||||
menu.addAction(open_file_action)
|
||||
|
||||
# Add Go to folder action
|
||||
go_to_folder_action = QAction("Go to folder", self)
|
||||
|
||||
def go_to_folder():
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
item = selected_items[0]
|
||||
display_name = item.text()
|
||||
for q in self.queue:
|
||||
if os.path.basename(q.file_name) == display_name:
|
||||
if not os.path.exists(q.file_name):
|
||||
QMessageBox.warning(self, "File Not Found", f"The file does not exist.")
|
||||
QMessageBox.warning(
|
||||
self, "File Not Found", f"The file does not exist."
|
||||
)
|
||||
return
|
||||
folder = os.path.dirname(q.file_name)
|
||||
if os.path.exists(folder):
|
||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
|
||||
break
|
||||
|
||||
go_to_folder_action.triggered.connect(go_to_folder)
|
||||
menu.addAction(go_to_folder_action)
|
||||
|
||||
@@ -466,6 +526,7 @@ class QueueManager(QDialog):
|
||||
def reject(self):
|
||||
# Cancel: restore original queue
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
|
||||
# Warn if user changed a lot (e.g., more than 1 items difference)
|
||||
original_count = len(self._original_queue)
|
||||
current_count = len(self.queue)
|
||||
@@ -475,7 +536,7 @@ class QueueManager(QDialog):
|
||||
"Confirm Cancel",
|
||||
f"Are you sure you want to cancel and discard all changes?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
@@ -485,7 +546,8 @@ class QueueManager(QDialog):
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
if event.key() == Qt.Key_Delete:
|
||||
self.remove_item()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
super().keyPressEvent(event)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# represents a queued item - book, chapters, voice, etc.
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedItem:
|
||||
file_name: str
|
||||
@@ -12,4 +13,4 @@ class QueuedItem:
|
||||
subtitle_mode: str
|
||||
output_format: str
|
||||
total_char_count: int
|
||||
replace_single_newlines: bool = False
|
||||
replace_single_newlines: bool = False
|
||||
|
||||
+29
-15
@@ -105,19 +105,21 @@ def clean_text(text, *args, **kwargs):
|
||||
|
||||
default_encoding = sys.getfilesystemencoding()
|
||||
|
||||
|
||||
def create_process(cmd, stdin=None, text=True, capture_output=False):
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Configure root logger to output to console if not already configured
|
||||
root = logging.getLogger()
|
||||
if not root.handlers:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
formatter = logging.Formatter('%(message)s')
|
||||
formatter = logging.Formatter("%(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
root.addHandler(handler)
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
|
||||
# Determine shell usage: use shell only for string commands
|
||||
use_shell = isinstance(cmd, str)
|
||||
kwargs = {
|
||||
@@ -151,11 +153,12 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
|
||||
|
||||
# Print the command being executed
|
||||
print(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
|
||||
|
||||
|
||||
proc = subprocess.Popen(cmd, **kwargs)
|
||||
|
||||
|
||||
# Stream output to console in real-time if not capturing
|
||||
if proc.stdout and not capture_output:
|
||||
|
||||
def _stream_output(stream):
|
||||
if text:
|
||||
# For text mode, read character by character for real-time output
|
||||
@@ -174,12 +177,14 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
|
||||
break
|
||||
try:
|
||||
# Try to decode binary data for display
|
||||
sys.stdout.write(chunk.decode(default_encoding, errors='replace'))
|
||||
sys.stdout.write(
|
||||
chunk.decode(default_encoding, errors="replace")
|
||||
)
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
stream.close()
|
||||
|
||||
|
||||
# Start a daemon thread to handle output streaming
|
||||
Thread(target=_stream_output, args=(proc.stdout,), daemon=True).start()
|
||||
|
||||
@@ -220,6 +225,7 @@ def get_gpu_acceleration(enabled):
|
||||
try:
|
||||
import torch
|
||||
from torch.cuda import is_available
|
||||
|
||||
if not enabled:
|
||||
return "CUDA GPU available but using CPU.", False
|
||||
if is_available():
|
||||
@@ -227,7 +233,11 @@ def get_gpu_acceleration(enabled):
|
||||
# Gather more diagnostic info if CUDA is not available
|
||||
try:
|
||||
cuda_devices = torch.cuda.device_count()
|
||||
cuda_error = torch.cuda.get_device_name(0) if cuda_devices > 0 else "No devices found"
|
||||
cuda_error = (
|
||||
torch.cuda.get_device_name(0)
|
||||
if cuda_devices > 0
|
||||
else "No devices found"
|
||||
)
|
||||
except Exception as e:
|
||||
cuda_error = str(e)
|
||||
return f"CUDA GPU is not available. Using CPU. ({cuda_error})", False
|
||||
@@ -239,6 +249,7 @@ def prevent_sleep_start():
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(
|
||||
0x80000000 | 0x00000001 | 0x00000040
|
||||
)
|
||||
@@ -246,19 +257,22 @@ def prevent_sleep_start():
|
||||
_sleep_procs["Darwin"] = create_process(["caffeinate"])
|
||||
elif system == "Linux":
|
||||
# use a sleep that never exits so inhibition stays active
|
||||
_sleep_procs["Linux"] = create_process([
|
||||
"systemd-inhibit",
|
||||
"--what=handle-lid-switch:sleep",
|
||||
"--mode=block",
|
||||
"sleep",
|
||||
"infinity",
|
||||
])
|
||||
_sleep_procs["Linux"] = create_process(
|
||||
[
|
||||
"systemd-inhibit",
|
||||
"--what=handle-lid-switch:sleep",
|
||||
"--mode=block",
|
||||
"sleep",
|
||||
"infinity",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def prevent_sleep_end():
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS
|
||||
elif system in ("Darwin", "Linux") and _sleep_procs[system]:
|
||||
try:
|
||||
|
||||
@@ -230,13 +230,15 @@ class VoiceMixer(QWidget):
|
||||
appstyle = QApplication.instance().style().objectName().lower()
|
||||
if appstyle != "windowsvista":
|
||||
# Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"]
|
||||
self.slider.setStyleSheet(f"""
|
||||
self.slider.setStyleSheet(
|
||||
f"""
|
||||
QSlider::groove:vertical:disabled {{
|
||||
background: {COLORS.get("GREY_BACKGROUND")};
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}}
|
||||
""")
|
||||
"""
|
||||
)
|
||||
|
||||
# Connect controls
|
||||
self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100))
|
||||
@@ -330,7 +332,9 @@ class VoiceFormulaDialog(QDialog):
|
||||
self._original_mixed_voice_state = None
|
||||
if parent is not None:
|
||||
self._original_profile_name = getattr(parent, "selected_profile_name", None)
|
||||
self._original_mixed_voice_state = getattr(parent, "mixed_voice_state", None)
|
||||
self._original_mixed_voice_state = getattr(
|
||||
parent, "mixed_voice_state", None
|
||||
)
|
||||
profiles = load_profiles()
|
||||
self._virtual_new_profile = False
|
||||
if not profiles:
|
||||
@@ -369,7 +373,9 @@ class VoiceFormulaDialog(QDialog):
|
||||
self.profile_list = QListWidget()
|
||||
self.profile_list.setSelectionMode(QListWidget.SingleSelection)
|
||||
self.profile_list.setSelectionBehavior(QListWidget.SelectRows)
|
||||
self.profile_list.setStyleSheet("QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }")
|
||||
self.profile_list.setStyleSheet(
|
||||
"QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }"
|
||||
)
|
||||
icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
|
||||
if self._virtual_new_profile:
|
||||
item = QListWidgetItem(icon, "New profile")
|
||||
@@ -976,6 +982,7 @@ class VoiceFormulaDialog(QDialog):
|
||||
def _parse_rgba_to_qcolor(self, rgba_str):
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QColor
|
||||
|
||||
"""Helper to convert 'rgba(R,G,B,A_float)' string to QColor."""
|
||||
match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str)
|
||||
if match:
|
||||
@@ -1372,6 +1379,7 @@ class VoiceFormulaDialog(QDialog):
|
||||
|
||||
def update_profile_list_colors(self):
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
profiles = load_profiles()
|
||||
for i in range(self.profile_list.count()):
|
||||
item = self.profile_list.item(i)
|
||||
@@ -1383,7 +1391,9 @@ class VoiceFormulaDialog(QDialog):
|
||||
color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND"))
|
||||
item.setData(Qt.BackgroundRole, color)
|
||||
else:
|
||||
item.setData(Qt.BackgroundRole, self.profile_list.palette().base().color())
|
||||
item.setData(
|
||||
Qt.BackgroundRole, self.profile_list.palette().base().color()
|
||||
)
|
||||
weights = profiles.get(name, {}).get("voices", [])
|
||||
total = 0
|
||||
if isinstance(weights, list):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
|
||||
# Calls parsing and loads the voice to gpu or cpu
|
||||
def get_new_voice(pipeline, formula, use_gpu):
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user