mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b15633995 | ||
|
|
8bc51dd270 | ||
|
|
d16c7603ce | ||
|
|
970fc6c9c0 | ||
|
|
d2f1c12de9 | ||
|
|
5d9409d391 |
@@ -1,3 +1,10 @@
|
|||||||
|
# 1.1.3
|
||||||
|
- `M4B (with chapters)` generation is faster now, as it directly generates `m4b` files instead of converting from `wav`, which significantly reduces processing time.
|
||||||
|
- Better sleep state handling for Linux.
|
||||||
|
- The app window now tries to fit the screen if its height would exceed the available display area.
|
||||||
|
- Fixed issue where the app would not restart properly on Windows.
|
||||||
|
- Fixed last sentence/subtitle entry timing in generated subtitles, the end time of the final subtitle entry now correctly matches the end of the audio chunk, preventing zero or invalid timings at the end.
|
||||||
|
|
||||||
# v1.1.2
|
# v1.1.2
|
||||||
- Now you can play the audio files while they are processing.
|
- Now you can play the audio files while they are processing.
|
||||||
- Audio and subtitle files are now written directly to disk during generation, which significantly reduces memory usage.
|
- Audio and subtitle files are now written directly to disk during generation, which significantly reduces memory usage.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.1.2
|
1.1.3
|
||||||
+51
-90
@@ -488,13 +488,27 @@ class ConversionThread(QThread):
|
|||||||
)
|
)
|
||||||
ffmpeg_proc = None
|
ffmpeg_proc = None
|
||||||
elif self.output_format == "m4b":
|
elif self.output_format == "m4b":
|
||||||
temp_wav_path = os.path.join(
|
# Real-time M4B generation using FFmpeg pipe
|
||||||
out_dir, f"temp_{base_name}{suffix}.wav"
|
static_ffmpeg.add_paths()
|
||||||
)
|
merged_out_file = None
|
||||||
merged_out_file = sf.SoundFile(
|
|
||||||
temp_wav_path, "w", samplerate=24000, channels=1, format="wav"
|
|
||||||
)
|
|
||||||
ffmpeg_proc = None
|
ffmpeg_proc = None
|
||||||
|
# Prepare ffmpeg command for m4b output
|
||||||
|
metadata_options = self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-thread_queue_size", "32768",
|
||||||
|
"-f", "f32le",
|
||||||
|
"-ar", "24000",
|
||||||
|
"-ac", "1",
|
||||||
|
"-i", "pipe:0",
|
||||||
|
"-c:a", "aac",
|
||||||
|
"-q:a", "2",
|
||||||
|
"-movflags", "+faststart+use_metadata_tags",
|
||||||
|
]
|
||||||
|
cmd += metadata_options
|
||||||
|
cmd.append(merged_out_path)
|
||||||
|
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
||||||
elif self.output_format == "opus":
|
elif self.output_format == "opus":
|
||||||
static_ffmpeg.add_paths()
|
static_ffmpeg.add_paths()
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -793,6 +807,7 @@ class ConversionThread(QThread):
|
|||||||
tokens_with_timestamps,
|
tokens_with_timestamps,
|
||||||
new_entries,
|
new_entries,
|
||||||
self.max_subtitle_words,
|
self.max_subtitle_words,
|
||||||
|
fallback_end_time=chunk_start + chunk_dur
|
||||||
)
|
)
|
||||||
if merged_subtitle_file:
|
if merged_subtitle_file:
|
||||||
subtitle_format = getattr(
|
subtitle_format = getattr(
|
||||||
@@ -819,6 +834,7 @@ class ConversionThread(QThread):
|
|||||||
chapter_tokens_with_timestamps,
|
chapter_tokens_with_timestamps,
|
||||||
new_chapter_entries,
|
new_chapter_entries,
|
||||||
self.max_subtitle_words,
|
self.max_subtitle_words,
|
||||||
|
fallback_end_time=chapter_current_time + chunk_dur,
|
||||||
)
|
)
|
||||||
if chapter_subtitle_file:
|
if chapter_subtitle_file:
|
||||||
subtitle_format = getattr(
|
subtitle_format = getattr(
|
||||||
@@ -910,8 +926,9 @@ class ConversionThread(QThread):
|
|||||||
if self.output_format in ["wav", "mp3", "flac"]:
|
if self.output_format in ["wav", "mp3", "flac"]:
|
||||||
merged_out_file.close()
|
merged_out_file.close()
|
||||||
elif self.output_format == "m4b":
|
elif self.output_format == "m4b":
|
||||||
merged_out_file.close()
|
ffmpeg_proc.stdin.close()
|
||||||
# Only generate chapters info if there are chapters
|
ffmpeg_proc.wait()
|
||||||
|
# Add chapters via fast post-processing
|
||||||
if total_chapters > 1:
|
if total_chapters > 1:
|
||||||
chapters_info_path = f"{base_filepath_no_ext}_chapters.txt"
|
chapters_info_path = f"{base_filepath_no_ext}_chapters.txt"
|
||||||
with open(chapters_info_path, "w", encoding="utf-8") as f:
|
with open(chapters_info_path, "w", encoding="utf-8") as f:
|
||||||
@@ -923,63 +940,26 @@ class ConversionThread(QThread):
|
|||||||
f.write(f"START={int(chapter['start']*1000)}\n")
|
f.write(f"START={int(chapter['start']*1000)}\n")
|
||||||
f.write(f"END={int(chapter['end']*1000)}\n")
|
f.write(f"END={int(chapter['end']*1000)}\n")
|
||||||
f.write(f"title={chapter_title}\n\n")
|
f.write(f"title={chapter_title}\n\n")
|
||||||
metadata_options = (
|
# Fast mux chapters into m4b (write to temp file, then replace original)
|
||||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
|
||||||
)
|
|
||||||
static_ffmpeg.add_paths()
|
static_ffmpeg.add_paths()
|
||||||
|
orig_path = merged_out_path
|
||||||
|
root, ext = os.path.splitext(orig_path)
|
||||||
|
tmp_path = root + ".tmp" + ext
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-y",
|
"-y",
|
||||||
"-i",
|
"-i", orig_path,
|
||||||
temp_wav_path,
|
"-i", chapters_info_path,
|
||||||
"-i",
|
"-map", "0:a",
|
||||||
chapters_info_path,
|
"-map_metadata", "1",
|
||||||
"-map",
|
"-map_chapters", "1",
|
||||||
"0:a",
|
"-c:a", "copy",
|
||||||
"-map_metadata",
|
tmp_path
|
||||||
"1",
|
|
||||||
"-map_chapters",
|
|
||||||
"1",
|
|
||||||
*metadata_options,
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-q:a",
|
|
||||||
"2",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart+use_metadata_tags",
|
|
||||||
merged_out_path,
|
|
||||||
]
|
]
|
||||||
proc = create_process(cmd)
|
proc = create_process(cmd)
|
||||||
proc.wait()
|
proc.wait()
|
||||||
self.log_updated.emit(
|
os.replace(tmp_path, orig_path)
|
||||||
("\nCleaning up temporary files...", "grey")
|
|
||||||
)
|
|
||||||
if os.path.exists(chapters_info_path):
|
|
||||||
os.remove(chapters_info_path)
|
os.remove(chapters_info_path)
|
||||||
else:
|
|
||||||
# No chapters: skip chapters info and related ffmpeg args
|
|
||||||
metadata_options = (
|
|
||||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
|
||||||
)
|
|
||||||
static_ffmpeg.add_paths()
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-y",
|
|
||||||
"-i",
|
|
||||||
temp_wav_path,
|
|
||||||
*metadata_options,
|
|
||||||
"-c:a",
|
|
||||||
"aac",
|
|
||||||
"-q:a",
|
|
||||||
"2",
|
|
||||||
"-movflags",
|
|
||||||
"+faststart+use_metadata_tags",
|
|
||||||
merged_out_path,
|
|
||||||
]
|
|
||||||
proc = create_process(cmd)
|
|
||||||
proc.wait()
|
|
||||||
if os.path.exists(temp_wav_path):
|
|
||||||
os.remove(temp_wav_path)
|
|
||||||
elif self.output_format in ["opus"]:
|
elif self.output_format in ["opus"]:
|
||||||
ffmpeg_proc.stdin.close()
|
ffmpeg_proc.stdin.close()
|
||||||
ffmpeg_proc.wait()
|
ffmpeg_proc.wait()
|
||||||
@@ -1146,7 +1126,7 @@ class ConversionThread(QThread):
|
|||||||
return f"{h}:{m:02}:{s:02}.{cs:02}"
|
return f"{h}:{m:02}:{s:02}.{cs:02}"
|
||||||
|
|
||||||
def _process_subtitle_tokens(
|
def _process_subtitle_tokens(
|
||||||
self, tokens_with_timestamps, subtitle_entries, max_subtitle_words
|
self, tokens_with_timestamps, subtitle_entries, max_subtitle_words, fallback_end_time=None
|
||||||
):
|
):
|
||||||
"""Helper function to process subtitle tokens according to the subtitle mode"""
|
"""Helper function to process subtitle tokens according to the subtitle mode"""
|
||||||
if not tokens_with_timestamps:
|
if not tokens_with_timestamps:
|
||||||
@@ -1191,9 +1171,15 @@ class ConversionThread(QThread):
|
|||||||
sentence_text = ""
|
sentence_text = ""
|
||||||
for t in current_sentence:
|
for t in current_sentence:
|
||||||
sentence_text += t["text"] + (t.get("whitespace", "") or "")
|
sentence_text += t["text"] + (t.get("whitespace", "") or "")
|
||||||
|
|
||||||
subtitle_entries.append((start_time, end_time, sentence_text.strip()))
|
subtitle_entries.append((start_time, end_time, sentence_text.strip()))
|
||||||
|
|
||||||
|
# Fallback for last entry
|
||||||
|
if subtitle_entries and fallback_end_time is not None:
|
||||||
|
last_entry = subtitle_entries[-1]
|
||||||
|
start, end, text = last_entry
|
||||||
|
if end is None or end <= start or end <= 0:
|
||||||
|
subtitle_entries[-1] = (start, fallback_end_time, text)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Word count-based grouping
|
# Word count-based grouping
|
||||||
try:
|
try:
|
||||||
@@ -1230,37 +1216,12 @@ class ConversionThread(QThread):
|
|||||||
subtitle_entries.append(
|
subtitle_entries.append(
|
||||||
(group[0]["start"], group[-1]["end"], text.strip())
|
(group[0]["start"], group[-1]["end"], text.strip())
|
||||||
)
|
)
|
||||||
|
# Fallback for last entry
|
||||||
def _write_ass_subtitle(
|
if subtitle_entries and fallback_end_time is not None:
|
||||||
self, file_path, subtitle_entries, is_centered=False, is_narrow=False
|
last_entry = subtitle_entries[-1]
|
||||||
):
|
start, end, text = last_entry
|
||||||
with open(file_path, "w", encoding="utf-8", errors="replace") as f:
|
if end is None or end <= start or end <= 0:
|
||||||
# Minimal ASS header
|
subtitle_entries[-1] = (start, fallback_end_time, text)
|
||||||
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):
|
def cancel(self):
|
||||||
self.cancel_requested = True
|
self.cancel_requested = True
|
||||||
self.should_cancel = True
|
self.should_cancel = True
|
||||||
|
|||||||
+24
-11
@@ -763,7 +763,11 @@ class abogen(QWidget):
|
|||||||
self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}")
|
self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}")
|
||||||
screen = QApplication.primaryScreen().geometry()
|
screen = QApplication.primaryScreen().geometry()
|
||||||
width, height = 500, 800
|
width, height = 500, 800
|
||||||
x, y = (screen.width() - width) // 2, (screen.height() - height) // 2
|
x = (screen.width() - width) // 2
|
||||||
|
# If desired height is larger than screen, fit to screen height
|
||||||
|
if height > screen.height() - 65:
|
||||||
|
height = screen.height() - 100 # Leave a margin for window borders
|
||||||
|
y = max((screen.height() - height) // 2, 0)
|
||||||
self.setGeometry(x, y, width, height)
|
self.setGeometry(x, y, width, height)
|
||||||
outer_layout = QVBoxLayout()
|
outer_layout = QVBoxLayout()
|
||||||
outer_layout.setContentsMargins(15, 15, 15, 15)
|
outer_layout.setContentsMargins(15, 15, 15, 15)
|
||||||
@@ -2815,6 +2819,23 @@ class abogen(QWidget):
|
|||||||
self.config["replace_single_newlines"] = enabled
|
self.config["replace_single_newlines"] = enabled
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
|
|
||||||
|
def restart_app(self):
|
||||||
|
from PyQt5.QtCore import QProcess
|
||||||
|
import sys
|
||||||
|
exe = sys.executable
|
||||||
|
args = sys.argv
|
||||||
|
|
||||||
|
# On Windows, use .exe if available
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
script_path = args[0]
|
||||||
|
if not script_path.lower().endswith('.exe'):
|
||||||
|
exe_path = os.path.splitext(script_path)[0] + '.exe'
|
||||||
|
if os.path.exists(exe_path):
|
||||||
|
args[0] = exe_path
|
||||||
|
|
||||||
|
QProcess.startDetached(exe, args)
|
||||||
|
QApplication.quit()
|
||||||
|
|
||||||
def toggle_kokoro_internet_access(self, disabled):
|
def toggle_kokoro_internet_access(self, disabled):
|
||||||
if disabled:
|
if disabled:
|
||||||
message = (
|
message = (
|
||||||
@@ -2838,11 +2859,7 @@ class abogen(QWidget):
|
|||||||
self.config["disable_kokoro_internet"] = disabled
|
self.config["disable_kokoro_internet"] = disabled
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
try:
|
try:
|
||||||
from PyQt5.QtCore import QProcess
|
self.restart_app()
|
||||||
import sys
|
|
||||||
|
|
||||||
QProcess.startDetached(sys.executable, sys.argv)
|
|
||||||
QApplication.quit()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(
|
||||||
self, "Restart Failed", f"Failed to restart the application:\n{e}"
|
self, "Restart Failed", f"Failed to restart the application:\n{e}"
|
||||||
@@ -2858,16 +2875,12 @@ class abogen(QWidget):
|
|||||||
)
|
)
|
||||||
if reply == QMessageBox.Yes:
|
if reply == QMessageBox.Yes:
|
||||||
from abogen.utils import get_user_config_path
|
from abogen.utils import get_user_config_path
|
||||||
import sys
|
|
||||||
|
|
||||||
config_path = get_user_config_path()
|
config_path = get_user_config_path()
|
||||||
try:
|
try:
|
||||||
if os.path.exists(config_path):
|
if os.path.exists(config_path):
|
||||||
os.remove(config_path)
|
os.remove(config_path)
|
||||||
from PyQt5.QtCore import QProcess
|
self.restart_app()
|
||||||
|
|
||||||
QProcess.startDetached(sys.executable, sys.argv)
|
|
||||||
QApplication.quit()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(
|
QMessageBox.critical(
|
||||||
self, "Reset Error", f"Could not reset settings:\n{e}"
|
self, "Reset Error", f"Could not reset settings:\n{e}"
|
||||||
|
|||||||
+5
-1
@@ -2,6 +2,7 @@ from json import load
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import platform
|
import platform
|
||||||
|
import atexit
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
from PyQt5.QtGui import QIcon
|
from PyQt5.QtGui import QIcon
|
||||||
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
|
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
|
||||||
@@ -9,7 +10,7 @@ from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
|
|||||||
# Add the directory to Python path
|
# Add the directory to Python path
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
|
||||||
|
|
||||||
from abogen.utils import get_resource_path, load_config
|
from abogen.utils import get_resource_path, load_config, prevent_sleep_end
|
||||||
|
|
||||||
# Set Hugging Face Hub environment variables
|
# Set Hugging Face Hub environment variables
|
||||||
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
|
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
|
||||||
@@ -27,6 +28,9 @@ from abogen.constants import PROGRAM_NAME, VERSION
|
|||||||
os.environ["MIOPEN_FIND_MODE"] = "FAST"
|
os.environ["MIOPEN_FIND_MODE"] = "FAST"
|
||||||
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
|
||||||
|
|
||||||
|
# Reset sleep states
|
||||||
|
atexit.register(prevent_sleep_end)
|
||||||
|
|
||||||
# Ensure sys.stdout and sys.stderr are valid in GUI mode
|
# Ensure sys.stdout and sys.stderr are valid in GUI mode
|
||||||
if sys.stdout is None:
|
if sys.stdout is None:
|
||||||
sys.stdout = open(os.devnull, "w")
|
sys.stdout = open(os.devnull, "w")
|
||||||
|
|||||||
+7
-2
@@ -244,6 +244,7 @@ def get_gpu_acceleration(enabled):
|
|||||||
|
|
||||||
|
|
||||||
def prevent_sleep_start():
|
def prevent_sleep_start():
|
||||||
|
from abogen.constants import PROGRAM_NAME
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system == "Windows":
|
if system == "Windows":
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -254,11 +255,15 @@ def prevent_sleep_start():
|
|||||||
elif system == "Darwin":
|
elif system == "Darwin":
|
||||||
_sleep_procs["Darwin"] = create_process(["caffeinate"])
|
_sleep_procs["Darwin"] = create_process(["caffeinate"])
|
||||||
elif system == "Linux":
|
elif system == "Linux":
|
||||||
# use a sleep that never exits so inhibition stays active
|
# Add program name and reason for inhibition
|
||||||
|
program_name = PROGRAM_NAME
|
||||||
|
reason = "Prevent sleep during abogen process"
|
||||||
_sleep_procs["Linux"] = create_process(
|
_sleep_procs["Linux"] = create_process(
|
||||||
[
|
[
|
||||||
"systemd-inhibit",
|
"systemd-inhibit",
|
||||||
"--what=handle-lid-switch:sleep",
|
f"--who={program_name}",
|
||||||
|
f"--why={reason}",
|
||||||
|
"--what=sleep",
|
||||||
"--mode=block",
|
"--mode=block",
|
||||||
"sleep",
|
"sleep",
|
||||||
"infinity",
|
"infinity",
|
||||||
|
|||||||
Reference in New Issue
Block a user