Improved process handling for subpprocess calls

This commit is contained in:
Deniz Şafak
2025-05-09 10:58:05 +03:00
parent 101012d9aa
commit 22086793ae
4 changed files with 121 additions and 55 deletions
+1
View File
@@ -2,6 +2,7 @@
- Improve chaptered audio generation by outputting directly as `m4b` instead of converting from `wav`. - Improve chaptered audio generation by outputting directly as `m4b` instead of converting from `wav`.
- Ignore chapter markers and single newlines when calculating text length, improving the accuracy of the text length calculation. - Ignore chapter markers and single newlines when calculating text length, improving the accuracy of the text length calculation.
- Prevent cancellation if process is at 99%, ensuring the process is not interrupted at the last moment. - Prevent cancellation if process is at 99%, ensuring the process is not interrupted at the last moment.
- Improved process handling for subpprocess calls, ensuring better management of subprocesses.
- Added `.opus` as output format for generated audio files. - Added `.opus` as output format for generated audio files.
- Added "Playing..." indicator for "Preview" button in the voice mixer. - Added "Playing..." indicator for "Preview" button in the voice mixer.
+7 -8
View File
@@ -8,14 +8,12 @@ from platformdirs import user_desktop_dir
from PyQt5.QtCore import QThread, pyqtSignal, Qt from PyQt5.QtCore import QThread, pyqtSignal, Qt
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import soundfile as sf import soundfile as sf
from utils import clean_text from utils import clean_text, create_process
from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
from voice_formulas import get_new_voice from voice_formulas import get_new_voice
import static_ffmpeg import static_ffmpeg
import threading # for efficient waiting import threading # for efficient waiting
import subprocess import subprocess
import sys
import platform
def get_sample_voice_text(lang_code): def get_sample_voice_text(lang_code):
@@ -509,7 +507,7 @@ class ConversionThread(QThread):
) )
if self.output_format == "opus": if self.output_format == "opus":
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
proc = subprocess.Popen( proc = create_process(
[ [
"ffmpeg", "ffmpeg",
"-y", "-y",
@@ -529,6 +527,7 @@ class ConversionThread(QThread):
chapter_out_path, chapter_out_path,
], ],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
text=False # Ensure binary stdin for audio data
) )
proc.stdin.write(chapter_audio.astype("float32").tobytes()) proc.stdin.write(chapter_audio.astype("float32").tobytes())
proc.stdin.close() proc.stdin.close()
@@ -605,7 +604,7 @@ class ConversionThread(QThread):
] ]
try: try:
print(f"Executing FFMPEG for Opus: {' '.join(ffmpeg_cmd_opus)}") print(f"Executing FFMPEG for Opus: {' '.join(ffmpeg_cmd_opus)}")
process = subprocess.Popen(ffmpeg_cmd_opus, stdin=subprocess.PIPE) process = create_process(ffmpeg_cmd_opus, stdin=subprocess.PIPE, text=False) # Ensure binary stdin
process.stdin.write(audio_data_np.astype("float32").tobytes()) process.stdin.write(audio_data_np.astype("float32").tobytes())
process.stdin.close() process.stdin.close()
if process.wait() == 0: if process.wait() == 0:
@@ -716,7 +715,7 @@ class ConversionThread(QThread):
self.log_updated.emit(f"Generating audio with chapters...\n") self.log_updated.emit(f"Generating audio with chapters...\n")
print(f"Executing FFMPEG for M4B: {' '.join(ffmpeg_cmd)}") print(f"Executing FFMPEG for M4B: {' '.join(ffmpeg_cmd)}")
process = subprocess.Popen(ffmpeg_cmd, stdin=subprocess.PIPE) process = create_process(ffmpeg_cmd, stdin=subprocess.PIPE, text=False) # Ensure binary stdin
process.stdin.write(audio_data_np.astype("float32").tobytes()) process.stdin.write(audio_data_np.astype("float32").tobytes())
process.stdin.close() process.stdin.close()
return_code = process.wait() return_code = process.wait()
@@ -725,13 +724,13 @@ class ConversionThread(QThread):
return output_m4b_path return output_m4b_path
else: else:
self.log_updated.emit( self.log_updated.emit(
(f"FFmpeg failed to create M4B (return code {return_code}). Falling back to WAV.\n", "red") (f"FFmpeg failed to create M4B (return code {return_code}).\n\nFalling back to WAV.\n", "red")
) )
sf.write(final_wav_path, audio_data_np, 24000, format="wav") sf.write(final_wav_path, audio_data_np, 24000, format="wav")
return final_wav_path return final_wav_path
except Exception as e: except Exception as e:
self.log_updated.emit((f"Error during M4B generation: {str(e)}. Falling back to WAV.\n", "red")) self.log_updated.emit((f"Error during M4B generation: {str(e)}.\n\nFalling back to WAV.\n", "red"))
try: try:
sf.write(final_wav_path, audio_data_np, 24000, format="wav") sf.write(final_wav_path, audio_data_np, 24000, format="wav")
return final_wav_path return final_wav_path
+28 -37
View File
@@ -695,7 +695,7 @@ class abogen(QWidget):
("wav", "wav"), ("wav", "wav"),
("flac", "flac"), ("flac", "flac"),
("mp3", "mp3"), ("mp3", "mp3"),
("opus", "opus (best)"), ("opus", "opus (best compression)"),
("m4b", "m4b (with chapters)"), ("m4b", "m4b (with chapters)"),
]: ]:
self.format_combo.addItem(label, key) self.format_combo.addItem(label, key)
@@ -1814,8 +1814,9 @@ class abogen(QWidget):
def add_shortcut_to_desktop(self): def add_shortcut_to_desktop(self):
"""Create a desktop shortcut to this program using PowerShell.""" """Create a desktop shortcut to this program using PowerShell."""
import sys, subprocess import sys
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
from utils import create_process
try: try:
# where to put the .lnk # where to put the .lnk
@@ -1834,47 +1835,37 @@ class abogen(QWidget):
# icon (fallback to exe if missing) # icon (fallback to exe if missing)
icon = get_resource_path("abogen.assets", "icon.ico") icon = get_resource_path("abogen.assets", "icon.ico")
if not icon or not os.path.exists(icon): if not icon or not os.path.exists(icon):
icon = target icon = target # Create a more direct PowerShell command
shortcut_ps = shortcut_path.replace("'", "''").replace("\\", "\\\\")
target_ps = target.replace("'", "''").replace("\\", "\\\\")
workdir_ps = os.path.dirname(target).replace("'", "''").replace("\\", "\\\\")
icon_ps = icon.replace("'", "''").replace("\\", "\\\\")
# Create PowerShell script as a single line with no line breaks (more reliable)
ps_cmd = f"$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('{shortcut_ps}'); $lnk.TargetPath='{target_ps}'; $lnk.WorkingDirectory='{workdir_ps}'; $lnk.IconLocation='{icon_ps}'; $lnk.Save()"
# Run PowerShell with the command directly
proc = create_process("powershell -NoProfile -ExecutionPolicy Bypass -Command \"" + ps_cmd + "\"")
proc.wait()
if proc.returncode == 0:
QMessageBox.information(
self,
"Shortcut Created",
f"Shortcut created on desktop:\n{shortcut_path}",
)
else:
QMessageBox.critical(
self,
"Shortcut Error",
f"PowerShell failed with exit code: {proc.returncode}"
)
# PowerShell command to make the shortcut
ps_cmd = r"""
$s = New-Object -ComObject WScript.Shell
$lnk = $s.CreateShortcut('{lnk}')
$lnk.TargetPath = '{tgt}'
$lnk.WorkingDirectory = '{cwd}'
$lnk.IconLocation = '{ico}'
$lnk.Save()
""".format(
lnk=shortcut_path.replace("'", "''"),
tgt=target.replace("'", "''"),
cwd=os.path.dirname(target).replace("'", "''"),
ico=icon.replace("'", "''"),
).strip()
subprocess.run(
["powershell", "-NoProfile", "-Command", ps_cmd],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
QMessageBox.information(
self,
"Shortcut Created",
f"Shortcut created on desktop:\n{shortcut_path}",
)
except subprocess.CalledProcessError as e:
QMessageBox.critical(
self,
"Shortcut Error",
f"PowerShell failed:\n{e.stderr.decode(errors='ignore')}",
)
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, "Shortcut Error", f"Could not create shortcut:\n{e}" self, "Shortcut Error", f"Could not create shortcut:\n{e}"
) )
def toggle_check_updates(self, checked): def toggle_check_updates(self, checked):
self.config["check_updates"] = checked self.config["check_updates"] = checked
save_config(self.config) save_config(self.config)
+85 -10
View File
@@ -1,4 +1,5 @@
import os import os
import sys
import json import json
import warnings import warnings
import platform import platform
@@ -102,6 +103,86 @@ def clean_text(text, *args, **kwargs):
return text return text
default_encoding = sys.getfilesystemencoding()
def create_process(cmd, stdin=None, text=True):
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')
handler.setFormatter(formatter)
root.addHandler(handler)
root.setLevel(logging.INFO)
kwargs = {
"shell": True,
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"bufsize": 1, # Line buffered
}
if text:
# Configure for text I/O
kwargs["text"] = True
kwargs["encoding"] = default_encoding
kwargs["errors"] = "replace"
else:
# Configure for binary I/O
kwargs["text"] = False
# For binary mode, 'encoding' and 'errors' arguments must not be passed to Popen
if stdin is not None:
kwargs["stdin"] = stdin
if platform.system() == "Windows":
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
kwargs.update(
{"startupinfo": startupinfo, "creationflags": subprocess.CREATE_NO_WINDOW}
)
# 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 proc.stdout:
def _stream_output(stream):
if text:
# For text mode, read character by character for real-time output
while True:
char = stream.read(1)
if not char:
break
# Direct write to stdout for immediate feedback
sys.stdout.write(char)
sys.stdout.flush()
else:
# For binary mode, read small chunks
while True:
chunk = stream.read(1) # Read byte by byte for real-time output
if not chunk:
break
try:
# Try to decode binary data for display
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()
return proc
def load_config(): def load_config():
try: try:
with open(get_user_config_path(), "r", encoding="utf-8") as f: with open(get_user_config_path(), "r", encoding="utf-8") as f:
@@ -148,21 +229,15 @@ def prevent_sleep_start():
0x80000000 | 0x00000001 | 0x00000040 0x80000000 | 0x00000001 | 0x00000040
) # ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED ) # ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED
elif system == "Darwin": elif system == "Darwin":
_sleep_procs["Darwin"] = subprocess.Popen(["caffeinate"]) _sleep_procs["Darwin"] = create_process("caffeinate")
elif system == "Linux": elif system == "Linux":
try: try:
_sleep_procs["Linux"] = subprocess.Popen( _sleep_procs["Linux"] = create_process(
[ "systemd-inhibit --what=sleep --why=TextToAudiobook conversion sleep 999999"
"systemd-inhibit",
"--what=sleep",
"--why=TextToAudiobook conversion",
"sleep",
"999999",
]
) )
except Exception: except Exception:
try: try:
subprocess.Popen(["xdg-screensaver", "reset"]) create_process("xdg-screensaver reset")
except Exception: except Exception:
pass pass